← all topics Game matching board · say it in this order 1 Require 4m2 Entities 1m 3 API 3m4 Design 10m 5 Dives ~20mreset
SAY THIS SENTENCE FIRST

"The scale math makes this a correctness and fairness problem, not a throughput problem: even at peak, every waiting room fits in one process's memory. So the truth lives in Postgres, one single-threaded matcher owns each (game, region) room, a guarded compare-and-set referees every claim, and a 5-minute deadline wall is enforced by machinery, not hope."

STEP 1 OF 5

Requirements

4 min
ASK, THEN COMMIT

Four questions. State your assumption for each so the interview moves even without answers.

  1. What is "skill" and who owns it?  Assume a 0-100 number from an external skill API, fetched once at join and snapshotted. The matcher never calls it.
  2. How big is a match?  Assume per-game config: K players, 2 to 10, and K includes the anchor. Bots may fill empty seats where the game allows.
  3. Is the 5 minutes a hard promise?  Assume yes: the game starts by the deadline even if match quality is not ideal, and the deadline is stamped at join and never moves.
  4. How strict is join order?  Assume roughly join order and nobody waits forever. Players can cancel while waiting and must hear about their match the moment it forms.
  • Out of scope, say it: running the game session (we ask that system for a server address and hand off), computing the skill number (the skill API owns it), and party queues (worth one sentence as an extension if asked).
Numbers to say

1M daily users × ~5 games/day ≈ 60 joins/sec average, 300-600/sec at peak (5-10x factor). Waiting population = arrival rate × average wait: at peak with a 30-60s wait, 10-40K people waiting. A couple hundred bytes each, so a few MB total: every room fits in one process's memory. 100K games, but maybe a thousand have an active room at any moment.

Hard rules

A match request ends in exactly one outcome: one match, or a cancel. Two matches claiming the same player is the disaster case. Players hear about a match within a second or two (one 500ms tick + the server-allocation call + one push). Postgres is the one hard dependency: it runs with a SYNCHRONOUS standby, which these tiny write volumes easily afford, so a failover can never un-commit a match a player was already told about.

DONE WHEN: interviewer nods at "correctness and fairness problem, not a throughput problem". That sentence licenses the single-threaded matcher later.
STEP 2 OF 5

Core entities

1 min
  • Match request: one waiting player. Skill snapshot (plus an estimated flag if the API was down), joined-at, a hard deadline of join time + 5 minutes, status WAITING → MATCHED or CANCELLED, and the match id once matched.
  • Match: the formed group. Which game, which players, how far apart their skills ended up, and whether it formed normally (FILLED) or because time ran out (TIMEOUT). Timed-out players still become MATCHED; the TIMEOUT tag lives on the match, not the player.
  • Game config: how many players a match needs (K, 2 to 10, includes the anchor), how fast the skill window widens, and whether bots may fill seats. 100K games get a global default curve with per-game overrides.
  • Skill: fetched from the external skill API once, at join, and saved on the request. External answers get recorded at the boundary, not re-asked in a loop.
DONE WHEN: you have said "the match_requests table in Postgres is the source of truth; everything else can be rebuilt from it."
STEP 3 OF 5

API

3 min
POST   /v1/games/{gameId}/queue     { region }   // region = where the player is; we match nearby first
  → 202 { requestId, skillSnapshot, deadline }   // deadline is stamped now and never moves
  → 409, body: { error: ALREADY_QUEUED, requestId }   // unique index on (userId, gameId) where status=WAITING; a retry gets the existing id back
GET    /v1/queue/{requestId}                        // polling fallback for status
WS     /v1/ws                                       // push: MATCHED { matchId, serverAddr }
DELETE /v1/queue/{requestId}                        // cancel; 200 while WAITING; 409 ALREADY_MATCHED or ALREADY_CANCELLED
PUT    /v1/queue/{requestId}/heartbeat              // stop sending these and we cancel you
  • 202, not 201: joining starts a process rather than creating a finished thing. The real answer arrives later, over the socket.
  • There is only one push type. A timed-out start is still a MATCHED push, because a timeout still forms a real match with a real server address.
  • The 409 on cancel exists because a cancel can race the match forming. Dive B covers who wins.
STEP 4 OF 5

High-level design

10 min, end to end, no dives yet
Whiteboard version: draw order
player → API → skill API snapshot (300ms timeout, estimated flag) → match_requests in POSTGRES (truth)   -- row 1: the door
one MATCHER per (game, region): 500ms tick, oldest anchor, widening window → guarded claim txn: match + K players + outbox   -- row 2: the loop
outbox → server allocation (key = matchId) → pub/sub by user → gateway WebSocket push; deadline wall checked every tick   -- row 3: the promise
mm-arch-v2
THE WALKTHROUGH, ONE BREATH PER CLAUSE

Joining writes the truth first: the API asks the skill API once with a 300ms timeout (down OR slow, same fallback: last known skill or 50, estimated flag set, so availability and join latency never degrade), stamps the hard deadline, and inserts the request as WAITING in Postgres before the player sees their 202. One single-threaded matcher owns each (game, region) room in memory; joins, cancels, and heartbeats reach it as messages, and every 500ms it matches from the oldest player outward with a skill window that widens as they wait → dive C. When it finds K, it claims match, players, and an outbox row in one guarded commit → dive B; the matcher then allocates a server on the critical path and publishes the MATCHED push, with the outbox row as the backstop, so a crash after commit still finishes → dive D. The soonest-deadline check every tick is what makes the 5-minute promise real: when the wall hits, the window no longer applies, and the game starts short-handed or with bots if the game allows, recorded as TIMEOUT.

The matcher tick, in detail (this is HLD, not a dive: say it here)
The anchor and the window
  • Start from the player waiting longest: the anchor. Their acceptable range grows with age: roughly ±5 just joined, ±15 after a minute, ±30 after three, anything goes as the deadline approaches.
  • An estimated-skill player starts at ±15, because pretending we know an estimated number to ±5 would manufacture bad matches.
The walk
  • Walk the queue in join order, collecting players inside the anchor's range, until K including the anchor.
  • Coherence check: also drop a candidate whose distance from the players already collected exceeds the window, so two teammates cannot be twice the window apart.
  • The window is one-sided, and that is a choice: admission is judged by the anchor's widened range, so a fresh player can be pulled into a wide match sooner than their own young window would allow. We accept that because it rescues long-waiting players faster; a stricter variant also checks the candidate's own window and pays with longer waits.
Head-of-line blocking, the classic failure of naive FIFO matching
  • If the anchor cannot match this tick, do NOT stop: try the next-oldest as anchor, bounded depth (say the 10 oldest). A lone skill-5 outlier at the head must not block six skill-90 players behind them.
  • The outlier is not abandoned: their window keeps widening, and their deadline is absolute. Age buys reach; the wall catches whoever reach never helped.
Cancel and notify, the two edges
  • Cancel and match are both a compare-and-set on the same WAITING status; whoever commits first wins. The cancel API does the CAS synchronously (200 or 409 immediately), then messages the matcher so the in-memory queue evicts that player rather than anchoring on a ghost.
  • The matcher does not hold sockets: it publishes MATCHED to a pub/sub channel keyed by user, and whichever gateway holds that socket delivers it. Gateway restarted: the client reconnects and the GET status endpoint catches it up. A duplicate push is harmless because it carries the same match id. Polling is the fallback for everything.
DONE WHEN: the interviewer picks a box to open. Let them steer from here.
  • If they just nod and wait: "the riskiest part is the claim transaction, shall I open exactly-one-match?"
  • Running behind: protect dives B and D. Dive E exists for the goalpost move to 100K QPS; do not open it unprompted.
STEP 5 OF 5

Deep dives: the core four

~20 min, interviewer steers
TRIGGER: "two matches claim the same player" / "walk me through the claim"
B · Exactly one match per request
ONE REFEREE · the guarded CAS in Postgres; the single writer is an optimization on top of it
Name the race first
  • Two writers reach for the same WAITING player: a stale matcher during a lease handover, or a cancel racing a claim. Adjacent-region matching (dive A) adds one more way for two rooms to want the same player.
  • The naive design, many matcher threads scanning a shared table with no referee, ends with two matches claiming one player: one match starts a player short, and nobody can explain why.
The claim transaction
BEGIN;
SELECT 1 FROM leases WHERE partition=$p AND epoch=$my_epoch FOR SHARE;
-- zero rows means we are a zombie: ROLLBACK. Takeover bumps the epoch with an
-- UPDATE, which conflicts with this FOR SHARE, so a stale owner cannot slip through.
INSERT INTO matches (id, game_id, players, formed_by) VALUES (...);
UPDATE match_requests SET status='MATCHED', match_id=$1
  WHERE id IN ($2...$k+1) AND status='WAITING';
-- row count must equal K, or ROLLBACK and retry without the missing player
INSERT INTO match_outbox (match_id) VALUES ($1);
COMMIT;
  • One transaction creates the match, claims all K players, and records an outbox entry saying "this match still needs a server and notifications".
  • If someone cancelled meanwhile, the row count comes back short of K, and the whole transaction rolls back.
Why one writer per room, and what the alternative costs
  • Give each (game, region) exactly one single-threaded matcher: match forming, cancels, and timeouts all become messages into one loop, so in steady state there is nobody to race. Affordable because one room fits in memory, and matching wants to scan a queue, which a single owner does naturally.
  • The competing-matchers alternative: SELECT ... WHERE status='WAITING' ORDER BY joined_at FOR UPDATE SKIP LOCKED, then claim. Name its cost before they do: skipping locked rows quietly abandons strict join order, the exact fairness this design is built on.
  • Be precise about which mechanism does what: the guarded UPDATE is the correctness mechanism; the single writer is a contention and simplicity optimization. A paused-then-waking zombie matcher, or an adjacent region reaching for your player, still exists; the CAS stops them, and the lease epoch makes a stale owner's claims recognizably stale.
TRIGGER: "nobody close in skill is online" / "how do you tune quality vs the promise"
C · Quality vs the 5-minute promise
ONE MECHANISM · age widens reach; the wall is enforced separately, so the promise never depends on the window
The tension, in one sentence
  • A strict skill window makes great matches and broken promises; a loose one keeps the promise while ruining the matches. A fixed window (say ±10 forever) fails at quiet hours: nobody is within 10, so players sit there until the deadline surprises everyone.
Greedy widening, chosen deliberately
  • Everyone starts picky and gets less picky: quality when the room is busy, certainty when it is not.
  • Drive each tick from the oldest matchable player, whose window is widest, and enforce the deadline separately with the soonest-deadline check. Order and widening become one mechanism; the promise never depends on the window.
Name the serious alternative before someone names it for you
  • A periodic batch optimizer: every few seconds, assign the whole pool to matches minimizing total skill spread subject to wait limits. It fixes greedy's blind spots (head-of-line waste, first-K instead of closest-K).
  • Decline it with reasons: K is small, rooms are small, strict join order is a stated requirement, and greedy with widening plus multi-anchor retry approximates it well at this scale. For team games with roles, revisit that choice.
The tuning dial
  • Watch one number: the fraction of matches that formed by timeout. If it climbs, the window curve is too strict or the game's population is too thin.
  • That metric, alongside p95 wait and per-match skill spread, drives every tuning conversation.
TRIGGER: "the matcher crashes" / "a match commits and THEN the matcher dies"
D · What happens when things die
3 SEPARATE FAILURES · process death, post-commit death, client death; separate them out loud
Truth in Postgres, memory as a cache of it
  • The memory-only version silently unqueues thousands of people on a crash; they wait forever for a match nobody is building.
  • Instead, every join was written to the table before the player got their 202, so a restarted matcher rebuilds its room by reading the WAITING rows. The claim transaction committed everything or nothing, so the queue cannot be half-claimed.
Leases with epochs own partitions
  • A matcher owns its partition through a lease row in Postgres with an epoch number. Process dies: the lease expires, a peer takes the partition at the next epoch and rebuilds from the table. Nobody is lost, and a zombie's stale epoch is refused.
The post-commit gap: the commit is not the finish line
  • A match can commit and the matcher can die before allocating a server or notifying anyone, leaving K players MATCHED with no game. That is why the claim writes an outbox row.
  • The takeover matcher sweeps unprocessed outbox entries, finishes the allocation call, and re-sends the notifications.
  • Both repeats are safe because each carries the match id: the allocation call uses it as an idempotency key (asking twice returns the same server), and a duplicate push is ignored by the client.
Heartbeats evict ghosts
  • Heartbeats flow to the matcher and live in memory, NOT written to Postgres per beat: 40K waiters beating every 10 seconds would be 4K writes/sec, dwarfing the 60-600 joins/sec we sized for. On takeover, everyone gets a fresh grace period.
  • When the beats stop, the matcher cancels that player with the same CAS as a user cancel. The point is to evict them BEFORE a match forms around them, so K-1 real players never start a game with a ghost teammate.
TRIGGER: "what changes if joins hit 100K QPS"
E · The goalpost move: 100K QPS
REDO THE MATH FIRST · the biggest flow is heartbeats, and it already bypasses the database
The new numbers, before touching architecture
  • 100K joins/sec is a different planet from our 300-600 peak. Waiting population = arrival × wait: 100K/s × 30-60s = 3-6M players waiting. Their state is still only about 1GB in aggregate, so memory is not the problem. Write throughput is.
  • Hunt for the biggest number first: it is not joins. 3-6M waiters heartbeating every 10s is 300-600K heartbeats/sec, three to six times the join rate. Dive D already keeps heartbeats out of Postgres; at this scale that decision stops being a nicety and becomes the design: the biggest flow never touches the database. What remains is to carry it through the front door and size every tier for it.
  • Downstream, with K=5: about 20K matches/sec, so 20K server allocations/sec, 100K MATCHED pushes/sec, 3-6M concurrent WebSockets that all want an answer within a second or two.
  • Say it out loud: the problem was correctness at small scale; now it is correctness AND throughput, and the win condition is keeping the exactly-one-outcome machinery while spreading the writes.
Why "buy a bigger Postgres" fails
  • Replicas absorb reads, but this workload is writes: 100K join inserts, ~20K claim transactions touching K rows each, plus cancels, every second, all on one primary and its one WAL. A single well-tuned primary tops out somewhere in the tens of thousands of small transactions per second: past the wall even with heartbeats already kept out.
  • The synchronous standby we rightly insisted on adds a network round trip to every commit, so the latency floor rises exactly when the volume explodes. And vertical scaling leaves one blast radius: when that primary fails over, every waiting room in the world stalls at once.
Shard on the key we already partition by
  • Shard Postgres by hash of (game, region), the same key the matchers partition on. The load-bearing observation: outside the adjacent-region case, all K players of a match live in the same (game, region) partition, so the guarded claim stays single-shard. The CAS referee survives sharding untouched.
  • With 64 shards, each sees roughly 1.6K inserts/sec plus a few hundred claim transactions, comfortable for a primary with a synchronous standby, and a failover now stalls 1/64th of the world instead of all of it.
  • The outbox and its sweeper go per-shard; pub/sub shards by userId. The one cross-shard case is the adjacent-region hand-off (dive A): keep it an ownership hand-off and form the rare cross-region match as two single-shard steps driven by the outbox (reserve the player at home, then form the match), never a distributed transaction. The reserve step is the same guarded CAS with the forming matchId stamped, so a crash between the steps is the familiar outbox-crash story: the sweeper finishes the match or frees the player.
A number and a bulkhead for every tier
  • API tier: count the real ingress: 100K joins + 300-600K heartbeats + cancels is 400-700K req/sec, and heartbeats are most of it. The staff move: take heartbeats off HTTP entirely. 3-6M players already hold WebSockets, so liveness rides the socket's ping/pong at the gateway for free, and the API tier sizes for the remaining ~100K rps, about 100 nodes at 1K each. Heartbeats stay in matcher memory, never Postgres; if someone suggests persisting liveness for the failover case, the takeover grace period from dive D already covers it.
  • Skill API: once per join is now 100K reads/sec against someone else's service. Snapshot-once already saved us from polling; add a short-TTL read-through cache, batch the lookups, keep the estimated-skill fallback.
  • Join ingestion: joins fan into rooms through a partitioned log keyed by (game, region). The log is transport, not truth: failover recovery still rebuilds the room from the table, exactly as dive D says.
  • Matchers: the model does not change, one single-threaded loop per room. ~10K active rooms, averaging 10 joins/sec per room, which is nothing. Raw heartbeats would be the real message load (a viral room with half a million waiters would take tens of thousands of beats/sec into one loop), so gateways aggregate liveness into a per-room digest every few seconds and the loop consumes digests, not beats. The viral-room math does change: at 10K joins/sec one room holds 300-600K waiting players; the per-tick work is the oldest anchor's window scan, not a full pass over the room, and if that scan ever stops fitting the 500ms tick, the room splits into skill bands with FIFO inside each band.
  • Gateways: 3-6M sockets at ~50K per node is 60-120 nodes. The failure that matters is the reconnect storm after an AZ loss: reconnects carry jitter and a resume token, and a reconnect never re-joins a queue, it re-attaches to an existing requestId.
  • Allocation: 20K matches/sec against a pre-warmed server pool. While the pool has headroom the call stays on the critical path for latency; when it runs dry, the outbox absorbs the burst and notification lags instead of matches failing.
  • Bulkheads and alarms: a degraded shard 503s joins for its own rooms and touches nothing else; the alarm is per-room p99 time-to-match against the 5-minute wall, not a global average that hides one dying shard.
Close by naming what survived and what was traded
  • Survived: exactly one outcome per request (single writer plus CAS, now per shard), the 5-minute wall, and join-order fairness, which was always per-room anyway.
  • Traded: no single place to query the whole world, so global reporting becomes an async rollup, and cross-region matches form in two steps instead of one transaction.
STEP 5, CONT.

Three more dives

rehearse after the core four
TRIGGER: "what about players in different regions"
A · Regional matching
ONE RULE · match local first; a hand-off is an offer, and ownership moves only on acknowledgement
The mechanism
  • Region is part of the partition key, so matching starts local.
  • When a player has waited long enough, their home matcher offers them to the adjacent region's matcher and keeps ownership (and the deadline) until an acknowledgement comes back; only then does the hand-off complete.
  • A lost offer therefore costs nothing: the player simply stays home and the offer repeats next tick.
  • The CAS referee backstops the brief window where both rooms know the player: this is one of the three races dive B names.
TRIGGER: "paid users skip the line" / "one game goes viral" / "match on more than skill"
F · Activity, priority, and the viral game
3 EXTENSIONS · each reuses an existing mechanism instead of adding one
Activity level (how recently and how much someone plays)
  • Make matching distance a weighted sum of skill difference and activity difference, compared against the same widening threshold. One mechanism handles any number of dimensions.
Priority (paid tiers, tournament re-queues)
  • Give those players a head start on the queue clock, capped at about 30 seconds, and leave their 5-minute deadline untouched.
  • The cap is the no-starvation argument, and the deadline index is what keeps the promise intact while the queue order bends.
The viral game
  • Check numbers before designing: even if one game somehow carried the entire system's peak of 600 events/sec, that is light work for one thread. If it ever truly breaks, split that one game's room into skill bands with FIFO inside each band.
One product decision to surface
  • A player can queue for two different games at once and win both. If the product wants exclusivity, a MATCHED push triggers cancels of the player's other WAITING requests; if not, it is allowed and documented.
TRIGGER: self-check the night before / "what would a staff answer add"
G · What each level is graded on
3 BARS · know which parts of this board carry the staff signal
Mid-level
  • A working queue, matcher, and timeout path, and knowing the skill API is asked once at join rather than polled during matching.
Senior
  • Nail the exactly-one-match machinery: the guarded claim, the cancel race, the outbox for the post-commit crash. Truth in the table with memory as a cache. The widening window stated as an actual curve.
Staff
  • Open with the scale math that turns this into a correctness problem. Name both alternatives (SKIP LOCKED fleets, batch optimization) and decline them with reasons. Spot head-of-line blocking in your own greedy design. Treat the timeout fraction as the product dial. Drive the exactly-one-match probe instead of waiting for it.
  • And when the interviewer moves the goalposts to 100K QPS: redo the scale math, notice the biggest flow (heartbeats) already bypasses the database, and size every tier for it before adding hardware.
FLASHCARDS

The five hardest probes

show all (interview mode)
Two matches claim the same player. Show me exactly where that becomes impossible.
In the claim transaction's guarded UPDATE: SET status='MATCHED' WHERE id IN (...) AND status='WAITING', and the row count must equal K or the whole transaction rolls back. Both racing claims target the same WAITING rows; whichever commits first flips them, and the loser's count comes back short, so its match, its player updates, and its outbox row all vanish together. The single writer per (game, region) means in steady state there is nobody to race, but it is an optimization, not the safety: a zombie matcher waking after a lease handover, or an adjacent region reaching for the same player, is stopped by the CAS, and the lease epoch check (FOR SHARE against the takeover's epoch bump) makes the zombie's transaction roll back before it writes anything.
A cancel races the claim. Who wins, and what does each side see?
Cancel and claim are both a compare-and-set on the same WAITING status in Postgres, so whichever commits first wins, full stop. Cancel first: the claim's guarded UPDATE matches K-1 rows instead of K, the transaction rolls back, and the matcher retries without the missing player; the canceller already got their 200. Claim first: the cancel's CAS matches zero rows and the API returns 409 ALREADY_MATCHED, which is honest, because the player is in a formed match with a server on the way. The cancel API does the CAS synchronously so it can answer immediately, then messages the matcher so the in-memory queue evicts that player rather than anchoring on a ghost.
The match commits, then the matcher dies before allocating a server or telling anyone. Now what?
The commit is not the finish line, and the design says so explicitly: the same transaction that claimed the players wrote a match_outbox row meaning "this match still needs a server and notifications". The lease expires, a peer takes the partition at the next epoch, rebuilds its room from the WAITING rows, and sweeps unprocessed outbox entries: it finishes the allocation call and re-sends the pushes. Both repeats are safe because each carries the match id: allocation uses it as an idempotency key, so asking twice returns the same server, and a duplicate push is ignored by the client. K players are never left MATCHED with no game.
Is the 5-minute promise actually guaranteed? Be honest about the bound.
It is enforced by machinery: the deadline is stamped at join and never moves, the matcher indexes requests by deadline as well as join order, and checks the SOONEST deadline every tick, which matters once priority reordering means the front of the queue is not the soonest deadline. When the wall hits, the skill window no longer applies: we still prefer the closest-skill waiters but accept whatever spread we must, and start short-handed or with bots where allowed. The honest bound: 5 minutes plus a small failover slack, lease timeout plus rebuild time, a few seconds, if a matcher dies at the wrong moment. And the synchronous standby means a Postgres failover can never un-commit a match a player was already told about.
What breaks first at 100K join QPS?
Not joins: heartbeats. 100K/s of joins with 30-60s waits means 3-6M concurrent waiters, and at a beat every 10 seconds that is 300-600K heartbeats/sec, three to six times the join rate. The design already keeps them out of Postgres (matcher memory only), so the fix is to carry that through the front door: liveness rides the WebSocket's ping/pong at the gateway, gateways aggregate it into per-room digests, and the API tier sizes for ~100K rps of real requests. The next wall is the single Postgres primary, which tops out in the tens of thousands of small write transactions per second; shard by hash of (game, region), the key we already partition on, so the guarded claim stays single-shard and the CAS machinery survives untouched, with failover blast radius down to 1/64th.