Let’s talk

The vanity alias race

Two people claim the same custom alias at the same second, and both are told it is theirs. Who gets it?

Before this: Analytics on the hot path

In 30 seconds

The problem
Every request that claims /launch at the same time is told it got it, but only one of them owns it. The others now share a link that sends their clicks to a stranger.
Why it happens
The handler checks that the alias is free and then saves it with an upsert. The check and the save are separate moments, and the upsert quietly overwrites instead of failing.
The fix
Insert with ON CONFLICT DO NOTHING RETURNING and let the unique index pick one winner.
In an interview
Let the unique index decide, in one statement. Zero rows back means taken, or your own retry. Never let an upsert turn a conflict into an overwrite.

Why it matters

A custom alias is a promise: /launch is yours. Checking that it is free and then saving it feels safe, and with one request at a time it is: the second person gets TAKEN. That is why it passes QA.

In our run, 50 users claimed /launch at once against a real Postgres with a primary key on the alias the whole time. All 50 were told CLAIMED. The alias belonged to one of them, and the other 49 were sharing a link that now sent every click to the winner's site. The index was there. The upsert walked straight through it.

Words you need

Race condition
A bug that only appears when two requests overlap in an unlucky order. Each request is correct on its own.
Unique index
A database rule that rejects a second row with the same value. A safety net: it turns a silent duplicate into an error.
Upsert
INSERT … ON CONFLICT DO UPDATE: insert the row, or overwrite it if one already exists. Handy for retries, dangerous when the conflict was the thing you needed to know about.
Idempotent
Safe to repeat. A retried request ends in the same state as the first attempt, instead of an error or a second effect.
Atomic
Done as one step that nothing can interrupt. Another request sees it either not started or fully finished.

Picture it

Alice and Bob both want /launch. Nobody has it yet.

One at a time (what QA does)

  1. Alice checks: free
  2. Alice saves
  3. Bob checks: taken

Alice gets CLAIMED, Bob gets TAKEN. Correct.

At the same moment (what launch day does)

  1. Alice checks: free
  2. Bob checks: free
  3. Alice saves
  4. Bob's upsert overwrites Alice

Both are told CLAIMED. The row now says Bob owns it, and Alice's link sends her visitors to Bob's site.

Break it yourself

Play: The vanity alias raceYou decide which request moves next. Find the order that breaks it, then try the fixes below. The game checks every possible order, not just yours.

The fixes people try

  • Read back after saving

    Fails
    save; SELECT owner; compare

    The last write wins and each request reads back its own write, so both see themselves as the owner and both report success.

  • A mutex in the API

    Fails
    await mutex.acquire()

    A mutex lives inside one process. Two servers have two mutexes, and their requests never wait for each other.

  • SELECT … FOR UPDATE

    Fails
    SELECT 1 FROM links … FOR UPDATE

    You cannot lock a row that does not exist yet. There is nothing to lock until someone has saved.

  • Advisory lock on the alias

    Works
    pg_advisory_xact_lock(hashtext(alias))

    The second request waits, then sees the first one's save. Correct, but it is extra machinery for something the index already does.

  • INSERT … ON CONFLICT DO NOTHING

    Works
    … DO NOTHING RETURNING code

    Claiming and checking are one statement. Exactly one insert gets a row back; every other one gets zero.

  • A plain INSERT, catch the duplicate

    Works
    catch (e) { e.code === '23505' }

    Same guarantee when your ORM has no ON CONFLICT: an exception instead of zero rows.

The fix and its cost

Drop the pre-check. Run one INSERT … ON CONFLICT DO NOTHING RETURNING code. Postgres serializes the inserts on the index: exactly one gets a row back, and everyone else gets zero rows and nothing changes.

The upsert was added so a retried request would not error, and that is a real need. Handle it by owner: if zero rows came back, read who owns the alias. Your own retry is a success; anyone else's claim is TAKEN. That is idempotency done right: decide by who owns the row, never by overwriting it.

What it costs

The losing requests need one extra read to tell a retry from a real conflict. If aliases are paid or need a confirmation step, hold the alias in a reservation table with an expiry instead.

Interviewers also listen for: when an upsert is right

ON CONFLICT DO UPDATE is fine when overwriting is the feature, such as "update my own link". Then add WHERE links.owner = EXCLUDED.owner, so it can only overwrite your own row.

Check yourself

  1. 1The alias column is a primary key. Why didn't that stop the duplicate?Show answer

    The upsert turns the conflict into an update. The one thing that would have caught the race was converted into a silent overwrite.

  2. 2Why doesn't SELECT … FOR UPDATE work here?Show answer

    It locks existing rows. Before anyone claims the alias there is no row, so there is nothing to lock.

  3. 3A user clicks Claim twice because of a slow network. How should that behave?Show answer

    The second request gets zero rows back, reads that the owner is the same user, and returns CLAIMED. A retry from the owner succeeds; a claim from anyone else is TAKEN.

The code

GitHub

claim-alias.bug.ts

import type { Pool } from "pg";

export async function claimAlias(db: Pool, alias: string, dest: string, owner: string) {
  // Check…
  const taken = await db.query("SELECT 1 FROM links WHERE code = $1", [alias]);
  if (taken.rowCount! > 0) return "TAKEN";

  // …then act. Everyone who checked before the first save gets here.
  await db.query(
    `INSERT INTO links (code, dest, owner) VALUES ($1, $2, $3)
     ON CONFLICT (code) DO UPDATE
       SET dest = EXCLUDED.dest, owner = EXCLUDED.owner`,
    [alias, dest, owner],
  );
  return "CLAIMED"; // the upsert never complains
}

Example code. The tested version, with a proof test against real Postgres, lands in the repo.

Star the repo for the runnable version

Each trap in this world ships with the bug, the fix and a proof test that fires concurrent requests at a real database.

Watch the 60-second version

Remember

A check followed by a save is two moments. Make it one statement and let the unique index decide.

Back to the world overview