Software · Oct 25, 2026 · 7 min read
Two people open the same booking page, pick the same Tuesday at 10:00 and press Book within a few milliseconds of each other. The code checks whether the slot is free, finds it free both times and saves both. This is the most common bug in booking systems and it never shows up in a demo, because a demo has one user. In the booking system we run ourselves, the one behind our own call booking page, it cannot happen: not because the code checks carefully, but because the database refuses the second booking.
The obvious version reads the bookings, decides the slot is free and inserts the new one. Each step is correct. The problem is the gap between them: two requests can both read before either writes, and both see a free slot.
| Moment | Request A | Request B |
|---|---|---|
| 1 | Reads the bookings for Tuesday: 10:00 is free | |
| 2 | Reads the bookings for Tuesday: 10:00 is free | |
| 3 | Inserts its booking at 10:00 | |
| 4 | Inserts its booking at 10:00 | |
| Result | Confirmed | Confirmed as well: two people, one slot |
Making the check faster, adding a cache or moving it to the front end only makes the window smaller. Under load, or with a retrying client, a small window still opens. The fix is to stop asking the application to guarantee something it cannot see: whatever else is being written at the same instant.
Two visitors ask the Rust API for the same 10:00 slot; both inserts reach PostgreSQL, where the exclusion constraint accepts the first one (201, booked) and rejects the second with error 23P01, which the API answers as 409 SLOT_TAKEN.
PostgreSQL can enforce "no two confirmed bookings overlap" by itself, with an exclusion constraint. A unique constraint says that no two rows may be equal; an exclusion constraint generalises that to any operator, here && (overlaps) between two time ranges. This is the table, taken from our migration:
create extension if not exists btree_gist;
create table bookings (
id uuid primary key default gen_random_uuid(),
starts_at timestamptz not null,
ends_at timestamptz not null,
status text not null default 'confirmed'
check (status in ('confirmed', 'cancelled')),
-- ...
constraint bookings_ends_after_start check (ends_at > starts_at),
constraint bookings_no_overlap
exclude using gist (tstzrange(starts_at, ends_at) with &&)
where (status = 'confirmed')
);Now replay the two requests. Both read a free slot, both insert, and PostgreSQL accepts the first and rejects the second with error 23P01, exclusion_violation. The race has not gone away; it simply has a referee.
A constraint violation is only useful if the application turns it into something a person understands. Our API (Rust, with sqlx) maps that one code to a conflict and lets any other database error through as what it is:
const EXCLUSION_VIOLATION: &str = "23P01";
fn slot_taken_on_overlap(error: sqlx::Error) -> ApiError {
let overlapped = error
.as_database_error()
.and_then(|database| database.code())
.is_some_and(|code| code == EXCLUSION_VIOLATION);
if overlapped {
return ApiError::slot_taken(); // 409 SLOT_TAKEN
}
error.into()
}The web branches on the status, never on the message: on a 409 the booking page says the slot has just been taken and reloads the free ones, so the visitor picks another without starting over. Moving a booking to another time goes through the same checks and is an update under the same constraint, so it cannot land on someone else either.
The constraint is the last word, not the only one. Before inserting, the API works out whether the requested start is bookable at all, and those rules belong in the application because they change with settings and with the outside world:
Being precise about the limits is part of the design. Our constraint guards the bookings themselves, not the buffer: the buffer is checked in the application. Two requests racing for neighbouring slots could, in theory, both be confirmed closer together than the buffer. They would never overlap, which is the failure that matters, and we accepted that trade-off on purpose, because the buffer is a setting and a constraint would freeze it into the schema.
If the buffer had to be absolute, the range in the constraint can carry it: tstzrange(starts_at - interval '15 minutes', ends_at + interval '15 minutes'). The price is a migration every time the buffer changes. The same reasoning applies to the daily maximum: it is a policy, enforced in code, not an invariant of the data.
A call is confirmed the moment it is saved. A rental is not: between choosing the dates and the payment going through there can be a minute or two, and during that time nobody else should be able to take the slot. In Boathous, the boat rental marketplace we built, the slot is held for five minutes while the customer pays, the check and the hold happen in one atomic step, and the payment's confirmation checks that the hold belongs to the person paying.
Whatever the store for the hold, three properties matter: checking and holding in one atomic operation, an expiry so an abandoned checkout releases the slot, and a final check at confirmation. A database-backed variant is to store the hold as a booking with its own status and expiry, covered by the same exclusion constraint, so the referee is the same for both.
Most real systems book more than one thing. The constraint then says that two bookings conflict only if they are for the same resource and their times overlap, and that equality on an ordinary column is exactly what btree_gist makes indexable next to the range:
exclude using gist (
boat_id with =,
tstzrange(starts_at, ends_at) with &&
) where (status = 'confirmed')If a booking system is the heart of what you are building, this is the kind of decision we make with the team in the first weeks of a custom software project: write down which rules must never break, and put each one where it cannot be skipped.
Only if every booking has the same length and starts on the same grid. As soon as durations differ or a booking can be moved, two bookings can overlap without starting at the same instant, and a unique index will not see it. An exclusion constraint compares ranges, so it catches any overlap.
A free slot has no row to lock, so locking the bookings you read does not stop someone inserting a new one. You would have to lock a parent row, such as the calendar or the boat, which works but serialises every booking for that resource and depends on every code path remembering to take the lock. The constraint holds even for the path that forgets.
Yes, PostgreSQL would abort one of the two transactions with a serialization failure (40001). But then every write must be ready to retry, and the rule itself is nowhere in the schema. The exclusion constraint states the rule once, next to the data.
MySQL has no exclusion constraints. The usual options there are locking a parent row per resource, or modelling the calendar as a table of fixed slots with a unique key per slot and resource.
AI · Oct 26, 2026 · 10 min read
In Fundaia, our own deployment platform, a failed deployment has a Fix with Claude button. How the agent diagnoses, reproduces and fixes inside a container that can only edit files, what the platform checks before opening the pull request, why it never merges it and what happens when the agent cannot fix anything.
Case studies · Oct 26, 2026 · 11 min read
Leaderbrock's sales team works across Europe and America. How we replaced a shared spreadsheet, an envelope of receipts and the lack of any map with one reactive Java backend, an Expo app and a web panel: expenses from a photo that AI drafts and a person files, an agenda in the phone's time zone and a GPS route that tells a flight from noise.
Case studies · Oct 26, 2026 · 12 min read
ReservPark notices through the car's Bluetooth that you parked, asks whether you are leaving when you walk back to the car and tells drivers nearby, and lets people rent out their garage by the hour or by subscription. How we built the native detection, the background location rules, the 30-minute free spots and garage bookings that cannot be oversold.