Backend Interview Question: Two Buyers Want the Last Seat. Who Gets It?

A small PostgreSQL example, a deliberately broken timeline, and the follow ups that turn a working demo into a defensible answer.
Both buyers saw the same available seat. Both clicked the button and both received a confirmation, and now somebody has a very awkward email to write.
Nothing about this requires a million requests per second. Two requests arriving in the wrong order are enough.
That is why I like this backend practice problem. You can draw an impressive architecture around it, but eventually somebody has to explain which operation is allowed to say that seat A7 belongs to one buyer.
Let’s work through an original interview style exercise, without pretending it came from a particular company. We control the inventory, use one PostgreSQL database for claims, and start with a single seat for a single event. Payment and expiring holds come later because they add different promises.
Start with the promise, because available is only an observation
Our first rule is that a seat can have at most one committed owner. We also need the application to avoid telling two different buyers that each owns it.
Those are related, but they are not identical. A database row can end up with one owner even after the application has sent two contradictory success messages.
For this exercise, a claim is final once committed. We are not implementing cancellation, seat transfers, or checkout timeouts yet. Buyer identity comes from the authenticated request, and the seat is identified by both event and seat number.
I prefer making those assumptions visible before reaching for a lock. Otherwise, we can spend ten minutes solving a reservation problem when the interviewer meant a temporary hold.
Watch the version that looks perfectly reasonable in a demo
Imagine the application reads the row, sees no owner, and then sends a separate update that assigns the buyer. Its update identifies the seat but does not require the owner to remain empty.
Buyer A reads the empty owner. Before A writes anything, buyer B reads that same empty owner. A assigns the seat to A and receives a success response. B then assigns the seat to B and receives another success response.
The final row contains B. That does not rescue A’s confirmation.
Wrapping this exact read-then-unconditional-write sequence in transactions is not, by itself, the missing explanation. You still need to explain how competing transactions are prevented from acting on the same earlier observation.
Disabling the button in the browser cannot coordinate two buyers either. Neither can a lock inside just one application process when requests can land on different instances.
Put the condition where ownership changes
Here is a deliberately small schema for the exercise. Each event seat pair has exactly one row, and an empty owner means unclaimed.
CREATE TABLE seat_claims ( event_id bigint NOT NULL, seat_no text NOT NULL, buyer_id bigint, PRIMARY KEY (event_id, seat_no) ); INSERT INTO seat_claims (event_id, seat_no) VALUES (42, 'A7'); Buyer 101 attempts this conditional update. Buyer 202 runs the same statement with their own authenticated identity.
UPDATE seat_claims SET buyer_id = 101 WHERE event_id = 42 AND seat_no = 'A7' AND buyer_id IS NULL RETURNING event_id, seat_no, buyer_id; Now the requirement that the seat is unclaimed belongs to the write itself. We have removed the gap between trusting an earlier check and assigning ownership.
Under PostgreSQL’s Read Committed behavior, competing updates to the same row coordinate through the database. If A holds the row while B attempts the update, B waits. When A commits, B checks its condition against the updated row. The owner is no longer empty, so B changes zero rows. PostgreSQL documents this rechecking behavior for concurrent updates.
One returned row means this statement assigned the seat in its transaction. Zero returned rows means it did not claim the seat. In our fixed fixture the seat exists, so zero means it is already claimed; a real endpoint must also decide how to handle missing or inaccessible seats.
This is a narrow solution for a narrow rule. Every ownership-changing path must respect the same rule. An administrative endpoint that blindly overwrites buyer_id would undo the guarantee we are trying to offer.
Also, concurrency safety does not promise fairness. We have not established that the person who clicked first will win. Either buyer can win depending on execution order.
Keep the celebration on the other side of COMMIT
Honestly, this is the part I want written beside the code in big letters.
If the statement runs inside an explicit transaction, receiving a returned row is not the same as committing that transaction. A later error can still cause a rollback. The application must not confirm ownership before it knows the commit succeeded.
Suppose a booking record must accompany the claim. Write both in one database transaction and roll back together if either operation fails. Our tiny example uses the seat row as the ownership record, so it intentionally leaves that extra table out.
Now let the commit succeed but lose the response on its way to the client. The buyer retries and the conditional update returns nothing because the seat is already owned. That does not necessarily mean their first attempt failed.
For a production contract, give the operation a durable identifier scoped to the authenticated buyer, persist its result with the claim, and let a retry recover that same result. Reusing an identifier with different intent needs a defined conflict response. The ownership predicate prevents competing claims; identifying the original operation resolves an ambiguous retry. Our short SQL statement implements the first part, not the entire retry contract.
Make the race happen deliberately in two terminals
Do this in a disposable local database with two independent connections, using Read Committed. The sequence below is a reproducible practice exercise with expected outcomes, not a claimed production benchmark.
- In connection A, begin a transaction and run the conditional update as buyer 101. Leave the transaction open after it returns A7.
- In connection B, begin another transaction and run the same update as buyer 202. It should wait while A holds the conflicting row lock.
- Commit A. B should finish without returning a row, and you can then commit B. Inspect the stored owner and expect 101.
- Reset the fixture after both transactions finish, then repeat with A rolling back instead. B should now be able to claim A7, and committing B should leave owner 202.
Keep the waiting transactions short, and clean up both connections if you interrupt the exercise. A lock timeout or another database error is an error path to handle, not evidence that somebody successfully acquired the seat.
Check what the database stores and what each caller is told. Two green HTTP responses are not a correctness test, and one final row is not proof that every earlier response was truthful.
Let the follow-up change the design instead of adding random boxes
An expiring hold changes our state model. Now we need a hold identity, an owner, an expiry rule, and a guarded transition to confirmed. An old timeout task must not release a newer buyer’s hold, so its update needs to identify the exact hold it is expiring.
Payment introduces another boundary. A database transaction cannot roll back a charge performed by an external payment provider. Avoid holding the seat’s database lock open while waiting for that network call. Design the hold, payment result, confirmation, and recovery rules explicitly, including what happens when payment succeeds after the hold expires.
For a useful variation, try this inventory design problem. Begin by deciding whether the service actually controls the stock. A reservation in your database cannot stop an unrelated shopper from taking an item off somebody else’s store shelf.
That is the habit I want this little seat example to build. Name the promise, find the exact write that protects it, and make two callers challenge it at the same time.
Before moving to your next problem, explain what changes when A rolls back and when A commits but loses the response. If those two stories sound different in your answer, you are practicing something much more useful than memorizing the word transaction.
~ Emily



