← All posts

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

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. The circles below are reads, and the squares mark possible writes. Play the race, or use Next operation to inspect each step. Then move Omar’s read to a later time:

One seat. Two remembered answers.

Follow the reads and writes. Then move Omar’s read past Adam’s write.

0246810AdamReadWriteOmarReadWrite
What each request remembers

Adam: not read yet

Omar: not read yet

What is actually stored

A7 → available

0 confirmations issued

The seat is available. Advance to the first read.

Illustrative time units, not measured latency. Circles are reads; squares mark possible writes. Each accepted write commits immediately. Writes use the earlier answer without checking again. Payment and booking-table constraints are omitted here. At equal timestamps, writes run before reads.

In the starting schedule, both reads happen before the first write, so 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 booking write commits, and that answer becomes false. But Omar is already holding the old answer, and he uses it anyway.

Now choose Try a later arrival and replay. Same code, same database, same two users. This time Omar reads after Adam’s write commits, 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 database transaction keeps your database changes all or nothing. If one database write fails, you can roll back the others. But a payment provider is outside that transaction. Rolling back the database cannot undo a card charge.

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. The database is not misbehaving here. It is doing exactly what its isolation rules allow.

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. In MySQL InnoDB’s default REPEATABLE READ, ordinary consistent reads usually share a snapshot established by the first such read in the transaction. That still does not turn a separate read and unconditional write into a safe claim.

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. You need a rule that prevents both requests from successfully claiming the same seat.

So what actually works?

Choose a rule below and step through its operations. Watch where Omar waits, gets a different answer, or has his write rejected:

Where does the second request stop?

Choose a rule, then advance one operation at a time. Watch the stored row and each request’s result separately.

Adam

Read: No read yet

Not finished

Omar

Read: No read yet

Not finished

Shared seat rowA7 · available
No lock held
0 / 4 operations

A7 is available. Neither request has run.

PostgreSQL READ COMMITTED, one illustrated schedule. Writes commit before the next write attempt. Concurrent conditional updates and unique inserts can wait on an uncommitted writer; their final result depends on its commit or rollback. Payments are outside this model.

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 you have to select that isolation level and handle transaction retries.)

So what is left? We need to make the availability rule part of the operation that claims the seat.

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. (With a conflicting concurrent update at stricter isolation levels, PostgreSQL can raise a serialization error. Retry that transaction when appropriate; a zero-row result at READ COMMITTED means you did not acquire the seat.)

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. Omar’s read is waiting for Adam’s transaction to end. 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 row lock blocks conflicting writes and locking reads, not ordinary snapshot reads. 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 booking per event and seat in the unique 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?

That can keep the row locked for far longer than necessary, and under load it can exhaust your database connections.

A lock lives until the transaction ends. So if the payment happens inside the transaction, the seat stays locked for the whole payment. The other buyers competing for that row have to wait for the slow payment before their locking reads can finish.

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, configure an appropriate timeout for the operation holding the lock too: 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.

Try a successful confirmation below. Then reset, let Adam’s hold expire, let Omar claim the seat, and deliver Adam’s late payment response:

A late payment cannot reclaim a seat.

Keep Adam’s hold ID while time moves forward. Does it still give him permission to confirm?

tickets.example/checkout
Tickets

Section A · Seat A7

A5A6A7A8A9
A7 is the seat used in this example.
Reservation statusSeat availableNo active hold

Use the controls below to claim the seat.

The database record
Seat
A7
Owner / hold ID
No hold
Stored expiry
None
Effective availability
Available
Adam’s payment request

Attached hold: None

Not started

This request keeps its original ID, even if the database gives the seat to somebody else.

Time moves forward. Reset to replay an earlier moment. A hold lasts 10 illustrative minutes.

Claim A7 for Adam, then choose when his payment response arrives.

Each claim and confirmation is a short atomic database operation. Payment authorisation, capture, and cancellation are external operations, represented as statuses here. This model demonstrates hold ownership and expiry; production systems also need durable payment recovery and idempotency.

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, you may attempt a fresh conditional claim, but a read saying “free” is not permission to book. If you cannot safely complete the booking, cancel the authorisation or refund a captured payment. Those are separate operations that can also need retries. 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.
  • Choose an expiry that covers normal checkout times, but still handle late payments. A timeout cannot guarantee that an external service has finished.

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.

One approach is to authorise the card while the hold is valid, confirm the booking, and then capture the money. An authorisation reserves the money without taking it, so a failed booking can cancel the authorisation instead of refunding a charge. Capture can fail too, so you still need durable retries and a way to reconcile the booking with the payment. Authorisation windows vary by payment method and provider; do not assume they match your seat hold.

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.)

How can we catch it locally?

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

Clicking in two browser tabs might reproduce it, but that is an unreliable test. A test that runs one request after another will miss the overlapping schedule entirely.

So make the schedule deliberate. Pause two requests after both have read availability, then release their writes. Assert that at most one booking succeeds. For a locking design, check that the second locking read waits, then observes the committed booking.

The demo lets you replay a chosen order for exactly this reason. Production timing is unpredictable, but a particular sequence of operations can be reproduced and tested.

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 tells you what was true when it ran. Make the write enforce the rule too, or protect the state between the check and the write.

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?

Get new articles by email.

New articles about software engineering.