Design an online chess platform

Systems design · Social and messaging · Jul 2026

The first decision that shapes everything in an online chess platform is that the client is just a screen. The server validates every move against its own copy of the board, runs both clocks in its own memory, and tells each client what happened, because any rule the client enforces is a rule a modified client can break. Chess.com and Lichess both work this way, and Lichess ships the whole thing as open source in lila, which makes this one of the few designs you can check against a real production codebase.

What makes the problem interesting is that a chess game is tiny and the platform is not. One game is two players, a board you can encode in a few hundred bytes, and a couple of minutes of play at the fast time controls most people choose. At peak we are running half a million of those games at once, which means a million open WebSockets, tens of thousands of matchmaking requests a second from players who re-queue the moment a game ends, and a leaderboard that ten million rated players each want their own rank from. The design below builds the plain version first, then spends most of its time on the four places the plain version breaks, matchmaking contention, the stateful game-server fleet, clock fairness across unequal networks, and the rank query.

The one-line version. The one-line version. An online chess platform is a stateful-session problem, not a CRUD problem. Matchmaking is an atomic find-and-claim on a Redis sorted set, gameplay is a WebSocket into a server that holds the board and both clocks in memory and persists every move before broadcasting it, crash recovery is replaying that move log behind a generation fence, and clock fairness is each player's measured latency credited back to their own clock, capped so it cannot be gamed.

Scope and requirements

Three requirements are worth designing for. A player finds an opponent of similar strength through matchmaking and a game starts. The two of them play that game in real time, with the server validating every move and running a shared pair of clocks. And every player can see a global leaderboard plus their own rank on it, fresh within moments of a game finishing. Everything else that makes these platforms sticky, spectating popular games, chat and friends, puzzles and post-game analysis, tournaments, sits below the line. So does anti-cheat, which is the existential threat to online chess but is an offline behavioral-analysis system, comparing move choices against engine top picks and move-time patterns against rating history, rather than part of the real-time path. Naming that distinction is worth more than trying to design it here.

Three constraints drive the architecture. Moves must propagate in under 200 milliseconds end to end, because bullet and blitz players have seconds per move and a laggy board is a broken product. Game state chooses consistency over availability. If a game server becomes unreachable, the game pauses rather than letting two clients drift into different positions, since a paused game can be recovered and a divergent one cannot. And the system must carry half a million concurrent games at peak, each with two players on their own connections, which is a million concurrent WebSockets plus the compute to validate every move and run two clocks per game.

Sizing the problem

Half a million games is a million persistent connections, and that number is the real load, not the data. A board position, whose turn it is, and two clock values fit in a few hundred bytes, so the entire live state of the platform at peak is on the order of a couple hundred megabytes, small enough for one laptop. What does not fit on one box is the connection handling and the per-move work. A server can hold tens of thousands of mostly idle WebSockets, but a game server is also validating moves and firing clock timers, so realistic per-node counts land lower and the fleet runs to dozens or low hundreds of machines. The hard parts follow from that shape. Both players of a game must land on the same machine, and a machine dying takes its games down with it.

Matchmaking load comes from a re-queue loop. Fast time controls dominate real platforms, a bullet game runs about two minutes end to end and blitz only a few more, and players queue again the moment a game ends. A million actively playing players each starting a fresh request every couple of minutes is about eight thousand match requests a second from that population alone, and evening peaks plus fresh arrivals push it into the tens of thousands. Every one of those requests is a range search over the pending pool followed by a claim on the chosen opponent, and the claim is where the naive design dies.

The leaderboard covers ten million rated players. The top-fifty page is cheap and barely changes, but every player wants their own rank, which makes the rank lookup the most common personalized read on the site, and it turns out to be the one query a relational index cannot answer efficiently.

Where the load actually is. Memory is never the constraint, a few hundred megabytes covers every live game at peak. The constraints are a million connections, an atomic claim under contention tens of thousands of times a second, and a rank query that is O(rank) if you do it the obvious way.

The API

Matchmaking and the leaderboard are ordinary request and response, so they are REST. Gameplay is a continuous exchange in both directions, the player sends moves up and receives the opponent's moves and clock updates down, so it rides a WebSocket. That is the opposite call from the brokerage design, where prices flow one way and SSE wins. The transport follows the direction of the traffic, not habit.

The matchmaking POST is deliberately a long-poll. It does not return when the request is created, it stays held open until the player is paired or the wait times out. That one choice deletes a whole notification channel from the design, because the player who was already waiting in the pool learns they were picked up when their own held request completes, and the player who triggered the pairing learns on theirs.

Identity never comes from the request. The playerId is read from the JWT and the rating is read from the Player record, because anything the client can put in a request body, the client can forge, and a client-supplied rating is a free pass to farm easy opponents. Candidates put userId in the body all the time and it is a reliable red flag.

POST /matchmaking                    -> MatchRequest  (long-poll, held until paired)
  { "timeControl": "blitz-3-2" }     // playerId from the JWT, never the body

WS   /games/:gameId
  client -> server:  sendMove      { from, to, moveNumber }
  server -> client:  moveAck       { accepted, reason?, whiteTimeMs, blackTimeMs }
                     opponentMove  { from, to, whiteTimeMs, blackTimeMs }
                     gameEnd       { result }

GET  /leaderboard?cursor={c}&limit={n}  -> Player[]  (sorted by rating)
GET  /players/:playerId/rank            -> { rank, rating }

Core entities and the data model

Four entities carry the design. A Player holds identity and the current rating. A Game holds the two playerIds and their colors, the time control, the result once it exists, and a snapshot of both players' ratings as they stood when the game started. A Move is one row per half-move, carrying gameId, moveNumber, the from and to squares, and a server timestamp, forming an append-only log per game. A MatchRequest holds a pending player's rating and chosen time control, kept separate from the Game so matchmaking has something of its own to work on.

The move log is the quiet centerpiece. It is the replay for post-game viewing, the evidence for disputes, and above all the crash-recovery mechanism for the game servers, because replaying a few dozen tiny rows rebuilds the exact board. The rating snapshot on the Game matters for the same reason, it makes derived data self-contained. A game's rating delta is computable from that one row forever, which is what lets the leaderboard update be idempotent and rebuildable later.

Postgres holds all of it. Move writes are the one meaty stream, on the order of a hundred thousand small appends a second at peak across the platform, but they partition perfectly by gameId, never contend with each other, and are append-only, so a partitioned or sharded table absorbs them mechanically. The live board is deliberately not in the database. It lives in game-server memory, and the database plays the role of a recovery log sitting off the hot path.

The pieces, and how they fit

Two paths hang off the API gateway. On the matchmaking path, the client's held POST reaches the Matchmaking Service, which pairs two compatible players and creates the Game row they both then open. On the gameplay path, each client upgrades to a WebSocket that the gateway routes to the Game Service instance owning that game, and the instance holds the board and both clocks in memory, appends each accepted move to the move log, and writes the result and new ratings when the game ends. The leaderboard endpoints read the Players table, later fronted by a sorted set once the rank query becomes the bottleneck.

The one deliberate architecture decision in this picture is that the Game Service is stateful. A game is a few hundred bytes that lives for minutes, so holding it in process memory is nearly free, validating against an in-memory board keeps the move path inside the latency budget, and the move log we already write gives us recovery. Offloading live state to a shared store starts paying off when per-session state is large or long-lived, and chess is neither. The price of the stateful choice is routing, since both players must reach the owning instance, and failure handling, since a crash must not corrupt a board, and each gets its own deep dive below.

Player clientstwo per gameAPI Gatewayauth, WS routingMatchmaking Servicepairs by ratingGame Serviceholds board and clocksPostgreSQLgames, moves, playersREST + WebSocketheld POST /matchmakingWS /games/:gameIdrequests, create Gameappend moves, results

The final design. Matchmaking pairs players and creates the Game row, then both clients open a WebSocket to the Game Service instance that owns the game. Postgres holds the durable record, while the live board and both clocks stay in Game Service memory.

Matchmaking that survives the stampede

The naive design keeps a pending-requests table and, on each request, queries for another pending row with the same time control and a rating within 200 points, then marks both matched. At eight to thirty thousand requests a second this fails structurally, not incrementally. A match is a range search followed by a read-modify-write claim, and the rating distribution concentrates most players in a thick middle band where nearly everyone is compatible with nearly everyone else, so the same few waiting players are simultaneously the best candidate for hundreds of incoming requests. Claim them with pessimistic row locks and every matcher serializes on that hot set. Claim them optimistically and most workers lose the compare-and-set and retry, burning attempts instead of waiting on locks. Two matchers can also read the same waiting player at the same instant and both pair them, double-booking one player into two games at once. No index fixes any of this, because the contention lives in the data, not the query plan.

The structure that fits is one Redis sorted set per time control, scored by rating. A pending player is a member whose score is their rating, finding candidates is a range query around your own rating, and claiming an opponent is removing them from the set. The races disappear by fusing find and claim into a single Lua script, because Redis executes scripts one at a time on its single thread, so no other matcher can observe the pool between your read and your remove. Exactly one worker can remove a given player, and double-booking becomes impossible by construction rather than by locking.

The window policy handles the rating extremes. Start narrow, around 100 points, widen every ten seconds or so of waiting, and expire the request after a minute rather than holding a player forever. Mid-band players match instantly at the narrow window and never see the widening. A 2700-rated player, who may have single-digit peers online, trades fairness for time explicitly, and real platforms tune the widening curve from measured wait-time data. Production systems also match on more than a point estimate. Lichess rates with Glicko-2, which carries a rating deviation alongside the rating, so a provisional player with a wide deviation can be paired more loosely because their number is not yet trustworthy.

The natural follow-up is whether one Redis node can carry this, and the arithmetic says yes with room to spare. Thirty thousand requests a second at roughly four sorted-set operations each is about 120 thousand operations a second in total, the busiest single time control is maybe forty percent of traffic, so around fifty thousand operations a second on its key, and a single Redis node serves simple sorted-set operations in the low hundreds of thousands a second. The pool itself is tens of thousands of small entries, well under a hundred megabytes. The node is still a single point of failure, so it runs replicated with automatic failover, and the saving grace is that pending requests are ephemeral. If a failover drops the pool, clients simply resubmit and it refills within seconds, a far lower bar than the game servers, where in-progress state actually matters.

# Runs as ONE Lua script on Redis. Single-threaded execution makes
# find-and-claim atomic: no other matcher sees the pool mid-flight.
def find_and_claim(pool, me, my_rating, window):
    lo, hi = my_rating - window, my_rating + window
    candidates = ZRANGEBYSCORE(pool, lo, hi, limit=8)
    for opp in candidates:
        if opp != me and ZREM(pool, opp) == 1:  # the claim: exactly one winner
            return opp                           # create the Game, complete both long-polls
    ZADD(pool, my_rating, me)                    # nobody compatible yet, wait in the pool
    return None

# A waiting player's held long-poll completes the moment someone claims them.
# Every ~10s unmatched, the worker retries with a wider rating window.
What does not work here.
  • A pending table with row locks. Every claim serializes on the hot mid-band players and the matcher grinds.
  • Optimistic compare-and-set on that table. The same hot set means most attempts lose and retry, a stampede in a different costume.
  • Sharded rating-band queues. The textbook answer, and the throughput math shows the hottest key runs at a third of one node's capacity, so sharding buys nothing but band-edge bugs where two near-equal players sit in different buckets and never meet.
  • Trusting the client's rating. It must be read server-side from the Player record, or sandbagging is one HTTP request away.

The move path through an authoritative game server

When the Game row is created, each client opens a WebSocket that lands on the instance holding that game, and the server binds the connection to the player's seat. From there a move follows one path. The server checks the move against its in-memory board, both that it is legal chess and that it is actually this player's turn. If it passes, the server applies it to the board, stops the mover's clock, starts the opponent's, appends the move to the durable log, and only then pushes an opponentMove to the other player and a moveAck to the mover, both messages carrying the authoritative clock times so neither client's display drifts far from the truth. An illegal move gets a rejecting ack and changes nothing. Checkmate, stalemate, the automatic draws, and flagging all fall out of the same validation code that already owns the rules, and on any of them the server writes the result and sends gameEnd to both sides.

The step order is the load-bearing detail. Persisting before broadcasting means a crash can never produce a recovered board that is missing a move a player already saw on screen, which is exactly the divergence we chose pausing to avoid. The write costs a few milliseconds inside a 200 millisecond budget, so this piece of correctness is nearly free, and it is the same intent-first discipline as writing an order as pending before submitting it to the exchange in the brokerage design.

The alternative worth naming is stateless game servers with the live board in shared Redis, so any instance can serve any move. It genuinely works for slow time controls, since the state is small and an in-zone Redis hop adds a millisecond or two. What it costs is clock authority. Flagging a player now depends on timers coordinated through an external store, every clock read becomes a network call, and you still have to serialize the two players' writes per game, which quietly reintroduces the exact claim race matchmaking just solved. One in-memory owner per game gets that serialization for free, and it is how Lichess actually runs, with each live game held as a small message-processing actor inside lila.

White's clientthe moverBlack's clientthe opponentGame Servicevalidate, swap clocksMove logPostgres, append-only1. sendMove {from,to}2. append before push3. moveAck + clocks3. opponentMove+clocks

The order is the point. The move is validated and applied in memory, persisted to the log, and only then broadcast, so a crash can never recover a board missing a move a player already saw.

The invariants of the move path. The server validates, the server owns the clocks, and every move is persisted before it is broadcast. Any design that relaxes one of these either lets a client cheat or lets a crash corrupt a board.

Scaling the fleet, and surviving a game server crash

Both players of a game must reach the same instance, so games are placed by consistent hashing on gameId over a ring of healthy servers, with membership tracked in a small registry like etcd or ZooKeeper through heartbeats. The gateway hashes the gameId, finds the owner, and pins the WebSocket there. Hashing on gameId rather than playerId is not a stylistic choice, since two players would hash to two different nodes and the design simply would not work. On a membership change, consistent hashing moves only the games owned by the dead or drained node, and deploys drain politely, new games stop landing on a node and short chess games mean it empties within minutes.

When a node dies, its games pause, which is the consistency-over-availability call made concrete. The ring reassigns each orphaned game to a new owner, and the new owner recovers by replaying the move log, a few hundred bytes per game that rebuilds the exact position and clock state in well under a millisecond. Clients reconnect carrying the last moveNumber they saw and the server replays them the gap. The operational sting is the reconnect storm, because a dead node dumps tens of thousands of clients into simultaneous reconnection, so clients back off with jitter and the gateway rate-limits the resync burst.

The subtle failure is the zombie. A server stuck in a long GC pause or cut off by a partition does not know it was declared dead, and when it wakes it still believes it owns its games while the ring has already moved them. Its late writes must not land. Every ownership transfer increments a generation number stored with the game, every move append is conditional on the current generation, and a write carrying a stale generation is silently dropped. That is a fencing token, and it is the entire mechanism failover needs, with no consensus protocol and no checkpointing anywhere in sight.

In production this placement layer is often a framework rather than a hand-rolled ring. Akka Cluster Sharding makes placement a directory a shard coordinator owns, Orleans does the same with virtual actors addressed by gameId, and process-per-match engines like Agones or AWS GameLift allocate a whole server per match for heavier games like shooters. None of them changes the chess answer. You still recover by replaying the move log and you still fence the old owner, the framework just moves games via a directory instead of a hash so healthy games never relocate on a membership change.

The staff-level judgment. The durable move log already is the recovery mechanism. Reaching for checkpoints, snapshot tables, or hot-standby replication of in-memory boards is the over-engineering trap, because a chess game is too small and too short to need any of it. The one thing failover genuinely requires beyond the log is the generation fence.

A clock the network cannot steal from

The server can only stop your clock when your move arrives, so with a plain server-authoritative clock every millisecond your move spends in flight is charged to you. A player 30 milliseconds from the server and a player 200 milliseconds away are not playing the same game. The far player pays about 170 milliseconds extra per move, and over forty moves of a three minute blitz game that is nearly seven seconds of pure network transit, easily the difference between winning and flagging. Nothing about their chess is worse, their packets just travel farther.

The fix is to measure and refund. The server pings each client continuously over the already-open WebSocket, keeps a running estimate of that player's round trip, and when a move arrives it credits the estimated one-way latency back to the mover's clock. The credit is capped, and this is where Lichess's production behavior is worth knowing. Compensation runs up to about one second per move, the limits depend on the time control and on how much lag has already been compensated, and a player's credit depends only on their own measured connection, never the opponent's. Meanwhile each client runs a local display clock for smooth rendering and every server message carries the authoritative times, so the display is continuously corrected. That makes six clocks in one game, a display pair on each client and the deciding pair on the server, and only the server's pair decides anything.

The rejected designs teach the invariant. Trusting a client timestamp of when the move was made hands free thinking time to any modified client. Synchronizing client clocks with NTP solves the wrong problem, since agreement about wall time does nothing about transit time and the reports still come from a machine the player controls. Uncapped compensation turns lag itself into a resource, because a client can delay its ping replies to inflate its measured latency and bank the refund. The cap bounds the theft, so the most a dishonest client can ever steal is the cap, which across a three minute game is noise.

Premoves push the same idea to its limit. A bullet player queues a reply before the opponent has even moved, the client fires it the instant the opponent's move renders, and the server applies it on arrival if it is still legal, charging effectively zero clock. If the opponent's actual move made the queued reply illegal, the server discards it cleanly. This is client-side prediction under server authority, the same shape as fast-paced multiplayer netcode, scaled down to a single move.

Also works, and does not. Crediting measured one-way latency with a cap is the production answer. A plain uncompensated server clock also works and is honest, it is just unfair to far players, which a global platform cannot accept. Client timestamps, NTP-synced clients, and uncapped credits all fail the same test, they let the player's machine influence the player's time.

A leaderboard that knows your rank out of ten million

The two leaderboard reads are nothing alike. The top page is trivial, since a btree on rating lets ORDER BY rating DESC LIMIT 50 walk fifty index entries and stop even with ten million rows, and a short cache on a page that barely moves finishes the job. Your own rank is the hard one. COUNT(*) WHERE rating > yours has to count every row above you, because a btree stores order but not position, so the cost is O(rank), millions of rows counted for the mid-pack majority who make most of the requests, and it is the most requested personalized read on the site.

A Redis sorted set holding all ten million players scored by rating answers both reads properly. ZREVRANK returns an exact rank in O(log n) from the skip list's positional bookkeeping, the top page is a ZREVRANGE, and the set takes two writes per finished game, one for each player's new rating. Ten million entries runs to a gigabyte or so of memory once per-entry overhead is counted, still comfortably one node's territory. If exactness can be relaxed, a bucketed histogram also works, keeping counts per ten-point rating band so a rank is the sum of the higher buckets plus a small scan within your own, approximate but tiny and rebuildable in seconds. What does not survive contact is running the COUNT per request at scale, or precomputing dense ranks into a table on a schedule, which is either stale or a ten-million-row rewrite per cycle.

Correctness comes from treating the rating as derived data. When a game ends, the durable result write on the Game row is the single commit point, and since that row snapshots both pre-game ratings, its rating delta is self-contained forever. An apply step fans the delta into the Players row and the sorted set, keyed by gameId so replays are no-ops, which turns a crash mid-update into a retry that corrects rather than double-counts. A periodic reconciliation recomputes from finished games and overwrites both stores, and the worst case is rebuilding the whole sorted set from the Players table, minutes of batch work. Nothing precious lives in memory, so a crash at game end is boring.

The rating math itself is a footnote here and a real decision in production. Plain Elo moves a single number up and down, by more when you beat a stronger player. Lichess uses Glicko-2, which tracks a rating deviation and volatility alongside the rating, so new players converge quickly, stale ratings are treated as uncertain, and matchmaking can read the deviation as a confidence interval instead of trusting a provisional number.

The senior insight. A rating is a fold over finished games, not precious live state. Once you see that, crash durability, idempotent updates, and full rebuilds all fall out of one design move, snapshotting the pre-game ratings on the Game row. The sorted set is just an external index over that record.

Questions and answers

The core ideas as questions with the answers given outright. Each wrong multiple-choice option is marked with why it is wrong, and the ordering ones show the correct sequence.

1The game server accepts a legal move, and the design appends it to the durable move log before pushing opponentMove to the other player. What does that ordering protect?
  • If the server crashed after broadcasting but before persisting, recovery would rebuild a board missing a move both players already saw, so persisting first means a recovered game never contradicts what players observed
  • It reduces move latency, because the database write can overlap with the broadcast. Persisting first adds a few milliseconds to the path rather than removing any, the ordering is bought for correctness, not speed.
  • It guarantees exactly-once delivery of the move to the opponent's client. Delivery to the opponent is still at-least-once over a socket that can drop, and gaps are healed by the client resyncing from its last moveNumber, not by the log ordering.
  • It keeps the two clocks synchronized, because the log write timestamps the clock swap. The clocks are authoritative in server memory and every pushed message carries their values, the log's position in the sequence has nothing to do with clock sync.
Why: The ordering is a crash-safety decision, the same intent-first discipline as writing an order as pending before calling the exchange in a brokerage. A synchronous append costs a few milliseconds inside a 200 millisecond budget, so the design buys its most important consistency property nearly for free. Broadcast delivery stays at-least-once, with reconnecting clients resyncing from their last seen moveNumber.
2A player who sits 200 milliseconds from the server keeps losing blitz games on time. What is the production fix for the time this player loses to the network?
  • Trust a client-side timestamp that says when the player actually made each move, and charge the clock from that. A modified client can backdate its timestamps and gain unlimited free thinking time, which is why no client-reported time can ever touch the authoritative clock.
  • Measure the player's latency with pings over the open WebSocket and credit the estimated one-way lag back to their clock on each move, with a hard cap on the credit
  • Synchronize both clients to the server with NTP so all clocks agree. NTP makes wall clocks agree but does nothing about the transit time of the move itself, and the sync reports still come from a machine the player controls.
  • Add a flat time bonus per move for both players so the network evens out. A flat bonus overcompensates the near player and undercompensates the far one, because latency is a per-player quantity that has to be measured per connection.
Why: The server can only stop a clock when the move arrives, so transit time is billed to the mover, about seven seconds over a forty-move blitz game at 170 milliseconds of extra distance per move. Lichess compensates measured lag at up to about one second per move, tuned by time control and already-granted compensation, and each player's credit depends only on their own connection. The cap matters because a client that fakes slow pings can otherwise bank free thinking time.
3At a peak of about thirty thousand match requests per second, the pending matchmaking pool has to be sharded across many Redis nodes to keep up.
  • True. The arithmetic says otherwise. About 120 thousand operations a second total, with the hottest key near fifty thousand, is roughly a third of a single node's sorted-set throughput, so sharding only adds moving parts and bucket-boundary matching bugs.
  • False
Why: Roughly four sorted-set operations per request is about 120 thousand operations a second in total, and the busiest single time control key carries maybe forty percent of that, around fifty thousand a second, while one Redis node serves simple sorted-set operations in the low hundreds of thousands a second. The hottest key sits at about a third of one node's capacity with the whole pool under a hundred megabytes, so sharding adds band-edge bugs where near-equal players land in different buckets and never meet, without buying throughput the system needs.
4Put the steps of one accepted move in the order the game server executes them.
  1. The client sends sendMove with the from and to squares over the game's WebSocket
  2. The server checks legality and turn against its in-memory board
  3. The server applies the move, stops the mover's clock, and starts the opponent's
  4. The server appends the move to the durable move log
  5. The server pushes opponentMove and moveAck, each carrying the authoritative clock times
Why: The tempting reorder is to broadcast before persisting so the opponent sees the move a few milliseconds sooner, and it is exactly wrong. A crash between broadcast and persist would recover a board missing a move both players already saw, the one corrupted state the design pauses games to avoid. Validation has to precede the clock swap too, since an illegal move must change nothing, drawing only a rejecting moveAck.
5A game server dies while owning twenty thousand live games. What does the replacement owner need to resume a game correctly?
  • A recent checkpoint or snapshot of each board, written every few moves to a snapshot table. Checkpoints solve a problem chess does not have, since replaying a few dozen tiny rows is cheaper than any snapshot machinery for a game this small and short.
  • Replay of the game's move log to rebuild the board and clocks, plus a new generation number so the dead server's late writes are fenced out
  • The clients' copies of the board, adopted once both players' clients agree on the position. Clients are untrusted and can disagree, and adopting their state inverts the entire authority model that makes server-side validation meaningful.
  • A shared Redis copy of the live board that every move was written to synchronously. Synchronous shared state per move redesigns the hot path around a network hop and external clock timers, and the durable move log already provides recovery without it.
Why: A chess game is a few hundred bytes of moves, so replaying the log rebuilds the exact position and clock state in under a millisecond, and the log already exists because it is written on every move. The only extra machinery failover needs is the fencing token, a generation number checked on every append so a zombie server that wakes from a GC pause cannot land writes on a game the ring already moved. Checkpoint schemes are the classic over-engineering trap here.
6With a btree index on the rating column, Postgres can answer a single player's exact rank among ten million players in logarithmic time.
  • True. The index finds your rating in O(log n) but not how many entries sit above it, so the count still touches every higher row, millions of them for a mid-pack player.
  • False
Why: A btree gives sorted order but stores no positional counts, so rank is COUNT(*) of every row above you, which is O(rank) and means counting millions of rows for the mid-pack majority who make most of the requests. A Redis sorted set answers ZREVRANK in O(log n) because its skip list tracks positions, which is why the rank read moves out of the database while the top-fifty page, a fifty-entry index walk, happily stays in it.

References

  1. Lichess, Is Lichess lagging?, How a production platform separates server processing latency from network latency and compensates each player's lag on the clock.
  2. lichess-org/lila on GitHub, The open-source chess server behind Lichess, an asynchronous Scala codebase whose MongoDB stores more than twelve billion games.
  3. Glickman, Example of the Glicko-2 system, The rating math with rating deviation and volatility that production platforms run in place of plain Elo.
  4. Redis, Sorted sets documentation, The one data structure doing double duty here, the matchmaking pool and the O(log n) leaderboard rank.
  5. Gambetta, Fast-Paced Multiplayer: Client-Server Game Architecture, Why authoritative servers exist and how client-side prediction relates to premoves.
  6. Akka Cluster Sharding documentation, Directory-based placement for stateful entities, the off-the-shelf alternative to a hand-rolled consistent-hash ring.