Preventing double-bookings with transactional slot locking
Two customers race for the last table at 2 a.m. Why check-then-write always loses, and how row locks plus a state machine make double-booking unrepresentable.
Every booking system has the same nightmare: two customers, one remaining slot, two requests arriving in the same hundred milliseconds. Building a reservation platform for a US restaurant group — event halls, catering, and tables sharing the same physical spaces — I got to solve it properly, because the previous 'system' (phone calls and memory) double-booked regularly.
Check-then-write always loses
The instinctive implementation reads availability, then writes a reservation. Between the read and the write, another transaction can do the same thing — both see 'available', both write, and you have two deposits for one hall. Adding an application-level mutex just moves the race to the next app instance.
-- The race, in two lines:
SELECT capacity_left FROM slots WHERE id = $1; -- both requests see 1
INSERT INTO reservations (slot_id, ...) VALUES ($1, ...); -- both insertThe fix is to make the database serialize the contenders: lock the slot row before checking, inside one transaction.
BEGIN;
SELECT * FROM slots WHERE id = $1 FOR UPDATE; -- second request blocks here
-- re-check availability under the lock
-- insert reservation, decrement capacity
COMMIT;FOR UPDATE turns the race into a queue. The second transaction blocks until the first commits, then re-reads — and sees the slot taken. The invariant lives where the data lives; no application-level cleverness can bypass it, including the admin panel and any future service that talks to the same database.
The state machine makes bad states unrepresentable
Locking prevents duplicate writes, but a reservation's life is longer than its creation: confirmation, payment, fulfillment, cancellation, no-shows. I modeled it as an explicit 8-state machine where every legal transition is enumerated and everything else is rejected at the API boundary.
const transitions: Record<State, readonly State[]> = {
draft: ["pending", "cancelled"],
pending: ["confirmed", "expired", "cancelled"],
confirmed: ["fulfilled", "no_show", "cancelled"],
// ... every state lists its legal successors; nothing else compiles past review
};
function transition(current: State, next: State) {
if (!transitions[current].includes(next)) {
return err({ code: "INVALID_TRANSITION", from: current, to: next });
}
return ok(next);
}- Cancelling a fulfilled reservation isn't a bug you catch in QA — it's a request the API cannot express.
- The transition table is also documentation: product conversations happen by pointing at it.
- State transitions and slot mutations commit in the same transaction, so the calendar and the lifecycle can never disagree.
Proving it: concurrency tests
The test suite includes tests that open parallel connections and hammer the same slot. The assertion is simple: exactly one succeeds, the rest get a typed SLOT_TAKEN error, and the slot's capacity never goes negative. Under load, double-bookings measured: zero. Which is the only interesting property of the design — not that the count is low, but that the architecture makes any other count impossible.
Concurrency bugs don't get fixed by care. They get fixed by making the database the referee and the invalid states unrepresentable.
I'm Mohsen— a full-stack engineer building real-time platforms and AI products. If you're working on something in this space, I'd love to hear about it.