Matching two AI models is easy — spin one up per user and you're done. Matching two humans, in real time, for a live conversation is a completely different problem: both sides are waiting, both sides can vanish, and if your matching logic has a race condition, two people can each think they're connected to someone — and be talking to nobody.

Here's how to build it properly: a fair matchmaking queue, a race-condition-proof pairing step, and a safety net so nobody ever sees "no one's online right now."

The shape of the problem

A learner taps "find a practice partner." From that moment, three things have to be true:

  1. If someone compatible is already waiting, pair them immediately.
  2. If two people arrive within milliseconds of each other, exactly one pair forms — never two people each thinking they're alone, never one person "stealing" both matches.
  3. If nobody shows up in time, hand over a graceful fallback instead of leaving them stuck on a spinner.

Most matchmaking bugs live entirely in requirement #2.

Step 1 — A queue, not a socket

Resist the urge to hold matching state in memory or in an open WebSocket. A simple queue row per searcher, in your regular database, is easier to reason about and survives a server restart:

// A learner joins the queue
interface MatchTicket {
  id: string;
  userId: string;
  status: 'searching' | 'matched' | 'expired' | 'cancelled';
  wantGender: 'any' | 'male' | 'female';
  wantLevel: 'any' | 'higher';   // "pair me with someone at or above my level"
  selfGender: string;
  selfLevel: number;
  expiresAt: Date;
  sessionId?: string;
}

The client polls a /match/status endpoint every second or two. Boring, stateless, and it means a dropped connection never strands a match — the row is still sitting there, waiting to be picked up on the next poll from either side.

Step 2 — The race condition, and the one line that fixes it

Here's the bug that will bite you if you skip this: two learners poll in lockstep, both find each other as a valid candidate, both try to lock their own row with SELECT ... FOR UPDATE SKIP LOCKED, and — because SKIP LOCKED skips a row someone else is already touching — both skip past the other's locked row and conclude nobody is available. Neither pairs. Both eventually time out to the fallback. It looks like a network problem. It is not.

The fix is one advisory lock around the whole pairing decision, not just the row write:

async function tryPair(myTicketId: string): Promise<boolean> {
  return db.transaction(async (tx) => {
    // Serializes ALL pairing attempts app-wide. It's a sub-millisecond,
    // DB-only operation, so contention costs nothing — but it's what turns
    // "two people mutually skip each other" into "one pairs, the other
    // observes the match a moment later."
    await tx.query('SELECT pg_advisory_xact_lock($1)', [MATCH_LOCK_KEY]);

    const me = await tx.oneOrNone(
      `SELECT * FROM match_tickets WHERE id = $1 FOR UPDATE`, [myTicketId]
    );
    if (!me || me.status !== 'searching') return false;

    const partner = await tx.oneOrNone(`
      SELECT * FROM match_tickets
      WHERE status = 'searching'
        AND expires_at > now()
        AND user_id != $1
        AND (want_gender = 'any' OR want_gender = $2)
        AND (want_level  = 'any' OR self_level >= $3)
      ORDER BY created_at ASC
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    `, [me.userId, me.selfGender, me.selfLevel]);

    if (!partner) return false;

    const sessionId = await createCallSession([me.userId, partner.userId]);
    await tx.query(
      `UPDATE match_tickets SET status='matched', session_id=$1
       WHERE id IN ($2, $3)`,
      [sessionId, me.id, partner.id]
    );
    return true;
  });
}

The advisory lock auto-releases the instant the transaction commits, so it's held for microseconds — but it's exactly what turns "two mutual skips" into "one clean pair." Call tryPair both when someone joins the queue and on every status poll — that's what gives you liveness without needing a background worker: the pairing happens the moment either side's poll notices the other.

Step 3 — Never overwrite a match that already happened

The second-nastiest bug: you read a ticket row, do some async work (mint a call token, check a quota), then write the whole row back. If your partner's tryPair matched this ticket in the meantime, your write clobbers their match — silently. The fix is boring but non-negotiable: never do a full-row save after a read; always do a scoped, state-guarded update.

// WRONG — overwrites whatever changed since you read `ticket`
await db.save(ticket);

// RIGHT — only moves the row if it's still in the state you expect
const { rowCount } = await db.query(
  `UPDATE match_tickets SET status = $1 WHERE id = $2 AND status = $3`,
  ['ai_fallback', ticket.id, 'searching']
);
if (rowCount === 0) {
  // Someone else moved this row first — re-read it and trust THAT.
}

Step 4 — The safety net: nobody waits forever

Real human availability follows the sun — plenty of matches at peak hours, almost none at 3am. A queue with no fallback means "no one's online" is a dead end. Give the search a timeout, and behind it, a seamless AI partner that speaks in the exact same room shape a human would:

async function onSearchTimeout(ticket: MatchTicket) {
  const persona = buildPersona({
    // Give the stand-in a stable name + backstory for the WHOLE call —
    // otherwise "where are you from?" gets a different answer every time.
    systemPrompt: `You are another learner on a practice call, not a
      teacher. React to what they say, ask easy follow-ups, never correct
      their grammar mid-conversation — save that for after the call.`,
    greeting: `Hey! Good to connect — how's your day been?`,
  });
  return mintAiPartnerRoom(ticket.userId, persona);
}

The learner never sees a "no match" screen — they see a partner who says hello. Behind the scenes it's a different room type, but the client-side call screen doesn't know or care.

Step 5 — Scoring a call without putting a robot in the middle of it

You don't want an AI talking in a human-to-human call — but you still want a scoreboard and corrections afterward. The trick: join a silent listener, not a conversationalist. It transcribes both sides and says nothing.

# A silent observer — joins the room, publishes no audio, speaks never.
async def observe(room):
    turns = []

    async def on_track(track, participant):
        async for transcript in speech_to_text_stream(track):
            if transcript.is_final:
                turns.append({
                    "userId": user_id_of(participant),
                    "text": transcript.text,
                    "timestampMs": transcript.offset_ms,
                })

    room.on("track_subscribed", on_track)
    room.on("participant_disconnected", lambda p: room.disconnect())

    await room.wait_until_closed()
    await post_transcript_to_backend(room.session_id, turns)

No language model runs inside the call. The transcript is handed to a review step afterward, off the critical path, which is what lets the review be slower and smarter than anything that could run live without adding lag to two people's conversation.

The idea worth keeping

A matchmaking queue isn't hard because the matching logic is hard — pairing two compatible rows is a five-line query. It's hard because concurrency is the actual problem: two people arriving at once, a poll racing a write, a partial update clobbering a fresh match. Solve those three specific races — one advisory lock, one scoped update, one silent observer instead of a live participant — and the rest of the feature is just plumbing.