Availability logic has a cruel property: it works perfectly during development, passes every test, survives the first months of production, and then fails on the busiest day of the year.
The reason is that almost every availability bug is a concurrency bug, and concurrency bugs need traffic to appear. Two customers clicking Book within the same 200 milliseconds is impossible to hit by hand and inevitable at scale. So the failure arrives precisely when the platform is doing well, in front of the most customers, with the least slack in the operation to absorb it.
This article covers how to build an availability engine that does not do that — for booking SaaS, parking, restaurants, clinics, rentals, and anything else that sells a finite resource against time. It comes from building and operating exactly these systems, including the multi-tenant booking platform behind BookFlow Pro.
The natural first implementation counts existing bookings and subtracts from a capacity number. Something like: find every booking overlapping this window, sum the units, compare to the total.
It is correct in isolation and wrong as an architecture, for three reasons.
It gets slower forever. The query scans a table that grows without bound, and the date-range overlap condition is exactly the kind of predicate that indexes handle poorly. A search that takes 40 milliseconds with 10,000 bookings can take several seconds with two million.
It cannot be locked meaningfully. To make the check-then-write sequence safe, you would have to lock every row it read, which under any real load produces either lock contention that stalls the site or a lock scope so broad that bookings serialise behind each other.
It has no place to record anything else. Overbooking allowances, holds, blackout dates, and channel-specific inventory all need somewhere to live, and a derived count has nowhere to put them.
The alternative is an explicit inventory ledger: a row per resource, per day (or per slot), holding the total units, the units consumed, and the units held. Bookings write to it; searches read from it. It is denormalised on purpose, and that is the point.
The unit of your inventory table determines what you can express.
The mistake worth avoiding is choosing per-slot granularity for a stay-based business because slots feel simpler. A car park booked from Friday afternoon to the following Thursday morning is not a slot; modelling it as one produces an availability engine that cannot answer the question customers actually ask.
For businesses that need both — a venue that hires rooms by the hour and also by the day — model the finer granularity and derive the coarser one, never the reverse.
This is the core of the whole subject. The sequence "read availability, decide it is fine, write a booking" is unsafe unless the read and the write are protected together.
Two approaches work, and both are fine.
Pessimistic locking. Inside a database transaction, select the relevant inventory rows with a row-level lock, check the remaining units, decrement, insert the booking, commit. A second concurrent request blocks at the select until the first commits, then reads the updated value and correctly finds nothing available. This is straightforward, easy to reason about, and what we use most often.
Optimistic concurrency. Perform a conditional update that only succeeds if sufficient units remain, and treat zero affected rows as a failure to be retried or reported. This avoids holding locks but needs disciplined handling of the failure case at every call site.
What does not work, regardless of how it is dressed up: checking availability in application code, then writing in a separate statement, without either mechanism. Nor does an application-level mutex if you run more than one server process, which you will.
Two practical details matter. Always acquire locks on inventory rows in a consistent order — ascending by date is the obvious choice — because a booking for the 14th to the 16th and another for the 16th to the 14th acquiring locks in opposite orders is a textbook deadlock. And keep the transaction short: never call a payment provider inside it, because holding inventory locks across a network round trip to an external API will bring the system to a standstill at exactly the wrong moment.
That last point creates an obvious problem. If you cannot hold locks across the payment call, what stops someone else taking the space while the customer is entering their card details?
The answer is a hold: a short-lived, expiring claim on inventory that is neither available nor booked.
The mechanics are simple. When a customer enters checkout, decrement available units and record a hold row with an expiry a few minutes out. If payment succeeds, convert the hold into a booking. If payment fails or the customer abandons, the hold expires and the units return.
The details that determine whether it works:
Holds are what make it possible to keep transactions short and still guarantee that a customer who reached the payment screen can complete.
Not every business wants availability to stop exactly at capacity. Airlines, hotels, and car parks routinely sell beyond physical capacity because a predictable share of customers do not turn up.
If you support this, make it explicit rather than emergent:
Overbooking without measurement is gambling. Overbooking with a year of no-show data is yield management.
More availability bugs come from time handling than from concurrency, they are just less dramatic.
The rules that prevent nearly all of them:
A related trap is the all-day or date-only value. A booking date of "14 March" is not a timestamp and should not be stored as one; converting it through a time zone at some later point will eventually turn it into the 13th.
Raw capacity is rarely the whole constraint. Real businesses have rules about when a slot can be sold at all:
Every one of these needs to be applied consistently in three places: the search that produces available options, the validation that runs at booking time, and any amendment flow. Rules enforced only in the search are trivially bypassed by anyone who keeps a stale page open, and amendment flows that skip validation are how impossible bookings get into a system that otherwise looks airtight.
Changing an existing booking is the single most common source of inventory corruption, because it is a release and an acquire that must both succeed or both fail.
A customer extends a stay by two days. The naive implementation adds two day rows and updates the booking. If the extension fails partway, or if the two new days are not available, the system can end up with inventory decremented for days the booking no longer covers, or a booking spanning days it never claimed.
Do the whole amendment in one transaction with the same locking discipline as a new booking: release the old range, acquire the new range, update the booking, commit or roll back entirely. And run a reconciliation job that periodically compares the inventory ledger against the sum of live bookings, reporting drift. You want to hear about a discrepancy from your own monitoring, not from an operator whose car park is full.
Ordinary unit tests will not find these bugs. What does:
Keep an explicit inventory ledger rather than deriving availability from bookings. Lock it properly on every write, in a consistent order, in short transactions. Use expiring holds to cover the payment step. Store everything in UTC with an explicit resource time zone. Apply booking rules in search, in validation, and in amendments alike. Treat amendments as atomic release-and-acquire operations. Reconcile continuously, and write the concurrency test.
Every one of those points exists because we have seen the alternative fail in production. If you are building a booking platform and want the availability model reviewed before it meets a bank holiday weekend, our contact page is the fastest way to reach us.
Looking to build something in Booking & Reservation SaaS?
See how we can help