Who Sold My Seat Twice?

The tiny gap between checking a thing and using it, and why it keeps selling one seat to two people

Ahmed Shamim Hassan

· ~2.8K words ·

Tickets for ‘AI conference’ go on sale at 10:00 AM sharp.

You have the page open three minutes early. You refresh at 09:59:58, you refresh again, and at 10:00:01 there it is. One seat left in Hall B, row A, seat 7. You click Buy. The card goes through. The confirmation email arrives with a QR code and a little “see you there!” at the bottom.

Two hours later, a second email. “We’re sorry. Due to an unexpected issue, your booking has been cancelled and refunded.”

Wait, what? Why?

What they didn’t tell you is, somebody else also bought seat A7!

But how did that happen? You got the confirmation email, and your payment was successful.

If it makes you feel any better, it’s not your fault.

And on the developer’s side, it’s most likely not a silly bug either. The check was correct. The write was correct. The seat count was correct. And the seat still got sold twice.

Let’s talk about why.

Two true answers

Here is how the booking code is probably written, in some form or another:

First it checks whether the seat is available. If it is, then it takes the money, writes the booking, and marks the seat as taken.

So there are two steps here. There is a check, and then there is a use. And between them, some time passes.

How much time? Rendering a page, a round trip to the payment gateway, maybe a retry on a slow network. It doesn’t sound like much, but every one of those milliseconds is a moment when the answer you are holding can quietly stop being true.

Let’s put two buyers on the same seat and see. Their names are Adam and Omar. Each bar below runs from the moment that man asks about the seat to the moment his payment lands. Drag either one:

interactive · one seat, two buyers

Drag either bar. Overlap them and the seat sells twice.

both told “1 seat left”adamasks “is A7 free?”pays · seat takenomarasks “is A7 free?”pays · seat takentime →

seat A7 sold twice · one seat, two tickets

omar asked before adam had paid, so he was told "1 seat left", true at that moment. the moment adam paid, it became false, but omar was already acting on the old answer.

A bar runs from asking to paying. Overlapping bars mean both men asked before either had paid.

They start overlapped, and both men are told “1 seat left”. The database is not lying to anybody here. Each of them asked at a moment when the seat really was free.

Then Adam’s payment lands, and that answer becomes false. But Omar is already holding the old answer, and he uses it anyway.

Now pull the bars apart. Same code, same database, same two users. This time Omar asks after Adam has paid, gets a polite “sold out”, and stops.

Nothing changed except when he clicked. And that is the whole problem: the bug is not in either step. It is in the gap between them.

So, what is this thing called?

It has a name, and the name is a mouthful:

TOCTOU: time-of-check to time-of-use. A class of software bug in which the state of a resource changes between the moment it is checked and the moment it is used, invalidating the result of the check.

Let me explain it in simple words.

You check something. Then you use what you checked. And between the two, somebody else changed it.

That’s it. That’s the whole concept. Everything else is just a variation of who that “somebody else” is: another request, another thread, another server, or a person with a script.

The tricky part is that your code looks fine when you read it. Line 14 checks, line 22 uses, and both are correct. Nothing is wrong, as long as your program is the only thing running in the world.

And once you notice this shape, you start seeing it everywhere:

  • You check that the username “batman” is free, then you create the account. Two people do it at the same time, and now there are two batmans. Not good.
  • You check that the coupon SAVE50 is unused, then you apply the discount. A single-use coupon gets used twice.
  • You check that the balance covers $100, then you debit it. Two withdrawals overlap and the account goes negative. Disaster!
  • You check that an API key has used 9 of its 10 calls, then you allow one more. A hundred requests all read 9, and all of them get allowed.

Notice the shape they share. It is always: check a condition on something shared, then act as if that condition still holds.

So whenever you write an if about some state, followed by a change to the state, you are looking at a candidate. Not always a bug, but a candidate for this issue.

The fix that isn’t a fix

The first instinct is usually: “well, I’ll just wrap it in a transaction.”

I’ve had that instinct too. It feels right. Transactions are what we reach for when concurrency gets scary, and this is concurrency, and it is scary.

But before reading on, take a two-minute break and try to answer this yourself: what does a transaction actually promise you?

Ready?

A transaction promises that your own steps happen all or nothing. If the payment succeeds but the seat write fails, the whole thing is rolled back, and you don’t end up half-booked. That is genuinely useful.

But it does not promise that anybody else will wait for you.

While Adam’s transaction is open, Omar can still ask “is A7 free?”, and he is still told yes. Neither database is misbehaving here. They are both doing exactly what their manuals say they do.

PostgreSQL runs at READ COMMITTED by default, so Omar’s query sees whatever was committed at the moment it ran. Adam hasn’t committed yet, so Omar sees the old value. MySQL defaults to REPEATABLE READ, which sounds stricter but doesn’t help either. There, Omar sees the seat as it looked when his own transaction started, which is even older.

So he reads “1 available”, quite legally, and walks into the same gap as before.

In other words, all-or-nothing is not the same thing as one-at-a-time. And only the second one would have saved the seat.

So what actually works?

Try the chips below. Same two buyers, same seat, five different sets of rules:

interactive · five ways to sell one seat

Same race, different rules

✗ sold twice
adamSELECT seat A7 → free…rendering, charging the card…UPDATE seat A7 SET taken · soldomarSELECT seat A7 → free…rendering, charging the card…UPDATE seat A7 SET taken · sold againtime
cost: free, until it costs you a seat

both reads were true. both writes were accepted. and the seat is gone twice.

Shaded bars are gaps between a decision and its write. Dashed bars are somebody waiting or being turned away. Only the last three chips ever end with one seat sold.

Check, then act is where we started. Two true answers, two writes, one seat sold twice.

Wrap in a transaction is the one we just talked about, and at the default isolation level it changes nothing. This is the dangerous one, because it looks so much like a fix that it gets shipped. (SERIALIZABLE, the strictest level, does catch it. But that is not what your connection string is using.)

So what is left? Well, stop asking the availability question first. That is the whole trick.

One atomic write does exactly that. Instead of “is A7 free?” and then, a moment later, “take it”, you say both things in a single statement: take A7, but only if it is still free.

Now the check and the write happen together, so there is no gap left for anybody to slip into. Then it tells you how many rows it changed. One row means the seat is yours. Zero rows means somebody was faster, and your code has to be ready to hear that. (At stricter isolation levels PostgreSQL raises an error instead of a quiet zero. Either way, you write a retry.)

Lock the row is the same idea, but pessimistic. SELECT ... FOR UPDATE makes Omar wait his turn instead of letting him read a stale answer. It works, but look at the price in the demo. That dashed bar is Omar, waiting. With nine thousand buyers and one popular seat, you have built a queue, and that queue is your checkout time on sale day. (One detail people get wrong: this lock holds off other writers who are using SELECT to UPDATE, not plain readers. A normal SELECT still reads the old value.)

Unique constraint comes at it from the other side. Don’t try to prevent the second booking, just let it fail. One row per seat in the index, so the second INSERT fails loudly, and you catch that error and show “sorry, sold out”.

Using an error as normal flow feels a bit odd, I know. But it is the safest one here, because the rule now lives in the schema instead of in one careful function. The import script somebody writes next year will obey it without even knowing it exists. It only works for named seats though. An index can say “A7 is booked once”, but not “this hall holds 400”.

So, different tools, same one idea: check and use should be a single step, or the second write should refuse to happen.

But the payment happens in the middle

Everything above assumed the write is one quick statement. But your real checkout is not. There is a payment provider sitting in the middle of it, and it takes a second or three.

So can’t we just hold the lock across the payment?

Please don’t. It will work fine on your laptop, and it will take the site down on sale day.

A lock lives until the transaction ends. So if the payment happens inside the transaction, the seat stays locked for the whole payment. Nine thousand buyers, three seconds per payment, and you are selling one ticket every three seconds.

And it gets worse. Every request waiting for that lock is also holding a database connection, so the pool runs out and pages that have nothing to do with ticketing start failing too. If the provider hangs, nothing saves you either: innodb_lock_wait_timeout and PostgreSQL’s lock_timeout only cut off the people waiting for a lock, not the one holding it.

So what do we do instead? We keep the slow part outside. The booking becomes two quick writes, with the payment sitting in between them:

  1. Claim the seat. One conditional write: take A7, but only if it is free or the last hold on it has already expired. Save your own hold id on it, and an expiry a few minutes ahead.
  2. Take the payment. No lock, and no open transaction.
  3. Confirm. Another conditional write: turn the hold into a booking, but only if the hold id is still yours and it hasn’t expired.

Here is the same booking, step by step, from both men’s screens:

interactive · the same booking, both screens

Claim the seat, pay, then confirm

seat A7 · free

adam

bananafest.com/seat/A7

Row A, seat 7

Banana Fest · Hall B

Buy ticket
seat A7nobody has claimed itno write yet

omar

bananafest.com/seat/A7

Row A, seat 7

Banana Fest · Hall B

Buy ticket
step 1 of 4

one seat left, and two people looking at it. adam clicks first.

Adam on the left, Omar on the right, and seat A7 in between. The database only does something on two of these steps.

You have seen this design before, on every ticket site. “Your seats are held for 10 minutes” is exactly this hold, with a countdown drawn on top of it.

Now, step 3 is the one people skip, and skipping it brings the whole bug back. Think about it. Adam’s payment is slow, his hold expires, and Omar takes the seat. Then Adam’s payment finally succeeds. If that last write has no condition on it, it will simply overwrite Omar’s booking.

And when the confirm changes zero rows, read the row before you react. If it is already booked with your own hold id, your confirm ran before and this is only a retry, so return success. If the hold expired but the seat is still free, claim it again. Only if somebody else holds it now have you really lost, and only then should you cancel the payment. In short, your confirm has to be safe to run twice, which is what people mean by idempotent.

A few more things about the hold:

  • Let the database compare the times, inside the same write. If you check the expiry in your application code, you are back to two steps again, on as many clocks as you have servers.
  • Every query that shows availability needs the same expiry rule, or expired holds will look like real bookings.
  • This works because the hold sits on the seat’s own row, where the next claim will look at it. If you keep a single counter of remaining seats instead, an abandoned checkout lowers it forever, and then you do need a cleanup job.
  • The expiry has to be longer than your slowest payment, retries included.

And there is one part that stays genuinely hard. What if the payment succeeds but your confirm fails? You and the payment provider do not share a transaction, so nothing rolls back on its own.

This is why ticket systems usually authorise the card when the hold is created, and only capture the money at confirm. An authorisation reserves the money without taking it, so if the booking fails you just cancel it and the customer is never charged. Do cancel it yourself though, because an authorisation can sit on a card for five to seven days, while your hold only lasts ten minutes.

And if you retry a payment, send the same idempotency key you used the first time, built from the hold. A new key each time is useless, because the provider treats it as a new payment.

What about a queue?

That is the other honest instinct. Put the requests in a line, handle them one at a time, and the race disappears.

It does. But it is worth knowing what you actually bought.

A queue in front of checkout is admission control. It turns a stampede into something your servers can survive, and it makes the waiting fair and visible. That is the “you are number 4,812 in line” page. Useful, but not the same thing as correctness.

You only get correctness if one worker can ever handle a given seat at a time, which means partitioning the queue by seat. Ten workers on one shared queue can still pick up two requests for A7 and race each other, exactly as before. And one global queue means one booking at a time for the whole site, which is a throughput ceiling you probably don’t want.

Most queues also deliver a message at least once, not exactly once. A worker can crash after charging the card and before acknowledging, and then somebody else gets that same message again.

So use both, for different jobs. The queue shapes the load. The conditional write is what makes overselling impossible.

It is not only about databases

I keep saying “the database”, but this is really a shared resource problem, not a SQL one.

The most famous version of it lives in the filesystem. A privileged program checks that a path is an ordinary file you own, then opens that same path and writes to it. Replace the name with a symlink in between, and the check answered a question about one file while the write landed on another. That one is CWE-367, and it has been showing up in setuid programs for decades.

The fix has the same shape as ours. Don’t check a name and then use the name, because the filesystem answers that question fresh every time you ask. Open the file once, and do the checking and the writing through that same handle. (There are more details to get right there, like making sure the open cannot follow a symlink, but that is a topic for another day.)

Why you’ll never see it locally

One last thing, because I think this is why the bug keeps getting shipped by careful people.

You cannot really test for it by hand. Reproducing it means getting two requests to overlap inside a window that might be twenty milliseconds wide. Clicking fast in two browser tabs won’t do it, and your integration tests, which run one request after another, will never do it.

The bug is not deterministic. It is a probability, and that probability is basically “how busy are you right now?” So it stays at zero in development, stays at zero in staging, and turns into a support inbox on launch day.

So the defence has to be a habit, not a test. Whenever you write “check that X is free, then take X”, stop and ask yourself one question: what happens if somebody else does this exact same thing, right now, between my two lines?

If the answer is “nothing good”, you already know the menu. Lock the row. Put the condition inside the write. Let a constraint refuse the duplicate. Any of them is better than hoping the gap stays empty.


So, that’s all for today. A check only tells you what was true. Only the write can tell you what is true. And if you want those two to agree, they have to be the same moment.

Thanks for reading this far! If I got something wrong, or oversimplified something in a way that misleads rather than helps, please let me know. I’d genuinely like to fix it.

And I’m curious: have you ever hit one of these in a real project? What was the resource everyone was fighting over, and how did you solve it?

New engineering notes, straight to your inbox. No noise.

No spamming. No hanky panky. Just focused contents.