Slate Setter.

engineering

Double Booking Is a Race, and Politeness Does Not Win Races

Two people clicking the same slot three hundred milliseconds apart is not a rare edge case. It is the normal behaviour of any calendar with demand.

The booking page shows an open slot at 10am. Two people are looking at it. Both click.

Whether you end up with one booking or two depends entirely on how the check and the write are arranged, and the arrangement almost everybody writes first is the one that produces two.

The shape that fails

Read whether the slot is free. It is. Write the booking.

Between the read and the write there is a gap. It is small. It is not zero, and under load it is not small: a slow query, a garbage collection pause, a retry, and the gap is tens of milliseconds, which is an eternity against two humans clicking at the same time after the same email went out.

Both requests read "free". Both write. The calendar now contains two bookings for one slot and the system reported success to both.

This is not an exotic concurrency problem. It is the single most common bug in booking software, and it reproduces reliably the first time a business sends a campaign.

The fix is in the database, not the code

The instinct is to add a check. Check again just before writing. Lock a variable in the application. Add a small delay.

None of that works, because the gap exists wherever the check is. Moving it closer to the write makes it narrower, which converts a reproducible bug into an intermittent one. That is strictly worse: it still happens, and now it happens rarely enough to be dismissed as the customer's mistake.

The reliable answers all push the decision into the storage layer, where a single operation can be atomic.

A unique constraint. If a booking is identified by resource and start time, a unique index on that pair makes the second insert fail. The database decides, atomically, and the loser gets an error rather than a confirmation. This is the simplest correct answer for fixed slots and should be the default.

A conditional write. Write the booking only if the slot's state is still what you read. Compare-and-set, or an update guarded by the version you saw. If nothing was updated, somebody beat you.

A transaction at the right isolation level. Repeatable read or serialisable makes the database detect the conflict and refuse one of the two. The PostgreSQL documentation on transaction isolation is the clearest explanation of what each level does and does not promise, and the key point is that the default in most systems is not strong enough to prevent this on its own.

For overlapping ranges rather than fixed slots, which is the harder case, the answer is a range type with an exclusion constraint if your database has one, and a serialisable transaction if it does not. Checking for overlaps in application code and then inserting is the failing shape again, with more arithmetic.

Losing well

Once the database is deciding, one of the two customers loses, and what they see is the part that is left to you.

The bad version is a generic error. "Something went wrong" after a customer entered their details and pressed Book reads as a broken site, and they leave.

The good version is specific and immediately actionable: that slot was taken a moment ago, here are the three nearest times, one tap each. Nothing they typed is lost. The whole interaction is four seconds and a small annoyance rather than an abandonment.

RFC 9110 has a status for this, 409 Conflict, which is the honest code for a request that conflicts with the current state of the resource. Returning 409 rather than 500 also tells any client that the request should not be blindly retried, which matters because a blind retry here books the next slot the customer did not choose.

The holds problem

Many systems hold a slot while the customer fills in the form. This fixes the race and introduces a different one.

An abandoned hold removes real availability. If holds last fifteen minutes and a third of people abandon, the calendar shows considerably less availability than exists, and the business never finds out, because the missing slots look like bookings that did not convert.

Three rules make holds safe.

Short. Two or three minutes is usually enough, and the expiry has to be enforced by the system rather than by a cleanup job that might not run.

Visible. Say that the slot is held and for how long. A countdown is honest and increases completion.

Released on failure. A payment decline, a validation error, a closed tab: every one of those should free the slot immediately rather than at the end of the window.

How to test it

Not by clicking fast in two browser windows. That is too slow to trigger it reliably and failing to reproduce proves nothing.

Fire the same booking request concurrently, a dozen times, at one slot. Then count the rows. One booking, eleven conflicts, and eleven responses a customer could act on is a pass. Two bookings is the bug, and it is in production right now in most of the booking software that has never run this test.

Sources

More on this