Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
A double-booked viewing or unit is a race condition: two requests check availability at once, both see the slot free, and both insert. An application-level check cannot stop this under load, because the gap between the check and the write lets a second request slip in. The reliable fix is a Postgres exclusion constraint, so the database refuses overlaps itself and two conflicting bookings can never both commit.
If you would rather have this fixed and tested for you, see how RAITHub would fix it below. First, why the usual code is not enough.
Why does a double booking happen even though I check availability?
Because the check and the write are two separate steps, and another request can run between them. The classic broken sequence:
// BROKEN under concurrency: check then write, with a gap in between.
const clash = await db.booking.findFirst({
where: { unitId, startsAt: slotStart }, // request A and B both run this…
})
if (clash) throw new Error('Taken')
await db.booking.create({ data: { unitId, startsAt: slotStart, userId } })
// …and both see no clash, so both create. Two bookings, same slot.
On a quiet system you never see it. Under load, two people clicking the same slot within milliseconds both pass the check. The same bug class causes oversold inventory in e-commerce (how to stop overselling stock); the cure is the same idea, moved to the database.
How does a Postgres exclusion constraint prevent overlaps?
An exclusion constraint tells Postgres that no two rows may both be true for a set of conditions, and the database enforces it atomically at write time, so there is no gap to exploit. For bookings, the condition is: same unit, and overlapping time range. It needs the btree_gist extension and a time-range column.
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- Store the booking as a time range, scoped to the unit.
ALTER TABLE bookings
ADD COLUMN during tstzrange NOT NULL;
-- No two bookings for the same unit may have overlapping ranges.
ALTER TABLE bookings
ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (
unit_id WITH =,
during WITH &&
);
The && operator means "ranges overlap". Now a second, conflicting insert fails at the database with a constraint violation, no matter how close together the two requests arrive. Your application catches that error and returns a clean "that slot was just taken" message. For a viewing, the range is the appointment window; for a tenancy, you can exclude overlapping lease periods on the same unit the same way.
What if you need to hold a slot before payment?
Viewings and short lets often reserve a slot for a few minutes while the user pays. Model the hold as a booking row with a pending status and an expiry, covered by the same exclusion constraint, so a hold blocks others immediately. A scheduled job releases expired holds. This is the reservation-inside-the-transaction pattern that short-let platforms rely on; the testing side is covered in QA for a short-let booking platform.
// Insert a pending hold atomically; the exclusion constraint rejects clashes.
try {
await db.$executeRaw(/* INSERT … status 'pending', during = [now, now+15min) */)
} catch (e) {
if (isExclusionViolation(e)) return reply('That slot was just taken.')
throw e
}
How do you prove the fix under real concurrency?
A single-threaded test will not catch a race, because it never runs two writes at once. Write a test that fires many concurrent booking requests for the same slot and asserts that exactly one succeeds and the rest get a clean rejection.
// Fire N concurrent requests for one slot; exactly one must win.
const attempts = Array.from({ length: 20 }, () => bookSlot(unitId, slot))
const results = await Promise.allSettled(attempts)
const ok = results.filter((r) => r.status === 'fulfilled').length
expect(ok).toBe(1) // one booking, not twenty
Run it against a real Postgres instance, not an in-memory stub, because the constraint only exists in the database. Gate it in CI so a future refactor that drops back to an application-only check fails the build. Put this alongside your other money and availability tests, as PropDesk and short-let builds do.
Buy, build or hire?
| Option | What you get | Choose this when |
|---|---|---|
| A scheduling or booking SaaS | Slot booking handled, with its own calendar and rules | A standard booking tool fits and you do not need it inside your own product |
| Patch it yourself | Add the exclusion constraint and a concurrency test | You run Postgres and can write a database migration and a concurrency test |
| A custom fix and hardening pass | The constraint, holds and expiry, a concurrency suite, and an audit of other race-prone paths | The double booking is one symptom and you suspect more concurrency bugs |
| A managed team on the platform | The build plus the regression tests that keep availability correct | Bookings are core to your product and must never clash |
How long does the fix take, and what is the risk of doing it yourself?
If you already run Postgres and use migrations, adding the btree_gist extension, the time-range column, the exclusion constraint and a concurrency test is roughly a half-day to two days, plus backfilling existing bookings into the new range column. The main risk of doing it yourself is backfill: existing rows may already overlap because of the old bug, and the constraint will refuse to apply until you resolve those conflicts. Clean the data first, then add the constraint, then prove it with a concurrent test.
How RAITHub would fix this
- Scope: reproduce the race; add the time-range column and the Postgres exclusion constraint; model pending holds with expiry and a release job; backfill and de-conflict existing bookings; add a concurrency suite gated in CI; audit other availability and money paths for the same bug class.
- Timeline: a focused fix and hardening pass sits in the 2 to 4-week code-rescue range; a larger build is phased.
- What you receive: the migration, the concurrency tests in CI, a reproduction and a before-and-after, handover docs, the work on accounts you own, IP assigned to you and an NDA as standard.
- Next step: a free 15-minute technical audit, then a fixed written quote.
RAITHub built PropDesk (1,024 tests, Stripe rent, leases, maintenance, four roles) and BlockEstate, a multi-tenant listings platform (6-week MVP, no MLS). See the SaaS development service, the real-estate industry page, the QA detail in short-let booking platform QA, and the sibling post on a portal that slows with thousands of listings. To get the incident diagnosed, book the free 15-minute audit.
Frequently asked questions
Why do I get double bookings when my code checks availability first?
Because the check and the write are separate steps with a gap between them. Under load, two requests both run the check, both see the slot free, and both write. Only a database-level guarantee closes the gap, because it enforces the rule atomically at write time.
What is a Postgres exclusion constraint?
A constraint that forbids two rows from both matching a set of conditions, such as the same unit and overlapping time ranges. The database enforces it when a row is inserted, so a conflicting booking fails with a clear error instead of silently committing.
Do I need the btree_gist extension?
Yes, to combine an equality check (same unit) with a range-overlap check (overlapping times) in one exclusion constraint. It ships with Postgres; enable it with CREATE EXTENSION IF NOT EXISTS btree_gist; before adding the constraint.
How do I hold a slot while someone pays?
Insert the booking immediately with a pending status and an expiry time, covered by the same exclusion constraint, so the hold blocks others at once. A scheduled job releases holds that expire unpaid. This keeps the reservation inside the transaction rather than relying on an application timer.
How do I test for a race condition?
Fire many concurrent requests for the same slot and assert that exactly one succeeds. Run it against a real Postgres database, because the constraint only exists there, and gate it in CI so a later change that drops back to an application-only check fails the build.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.