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)
- Alice checks: free
- Alice saves
- Bob checks: taken
Alice gets CLAIMED, Bob gets TAKEN. Correct.
At the same moment (what launch day does)
- Alice checks: free
- Bob checks: free
- Alice saves
- 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
Failssave; SELECT owner; compareThe 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
Failsawait mutex.acquire()A mutex lives inside one process. Two servers have two mutexes, and their requests never wait for each other.
SELECT … FOR UPDATE
FailsSELECT 1 FROM links … FOR UPDATEYou cannot lock a row that does not exist yet. There is nothing to lock until someone has saved.
Advisory lock on the alias
Workspg_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 codeClaiming and checking are one statement. Exactly one insert gets a row back; every other one gets zero.
A plain INSERT, catch the duplicate
Workscatch (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
Interviewers also listen for: when an upsert is right
Check yourself
1The alias column is a primary key. Why didn't that stop the duplicate?Show answerHide
The upsert turns the conflict into an update. The one thing that would have caught the race was converted into a silent overwrite.
2Why doesn't SELECT … FOR UPDATE work here?Show answerHide
It locks existing rows. Before anyone claims the alias there is no row, so there is nothing to lock.
3A user clicks Claim twice because of a slow network. How should that behave?Show answerHide
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
claim-alias.bug.ts
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