Availability is the hardest read in any booking product. A clinic month view has to answer one question — when can this patient actually book? — against opening hours, each doctor's own hours, split shifts, existing appointments, vacations, clinic closures, per-service daily caps, buffers between services, times that have already passed, and a customer-facing grid that is coarser than the internal one. Multiply that by thirty days and ten doctors and it is the query that decides whether the product feels fast.
We built it the way most people do the first time: materialise the day as an array of slots, then filter. Generate 288 five-minute entries, walk the appointments and knock entries out, walk the vacations and knock more out, then for every candidate start time walk forward D entries to check the service fits. It is readable, it is obviously correct, and every single rule costs another full pass over the array.
The reframe was to stop storing slots and start storing a number.
A day is a 288-bit integer — one bit per five-minute slot.
288 bits is 24 hours at five-minute granularity (SLOT_GRANULARITY_MINUTES = 5). It has to be a BigInt rather than a plain number, because JavaScript numbers only carry 53 bits of integer precision — a day does not fit in a double. Once a day is one integer, every scheduling concept collapses into a bitwise operation:
| Concept | Operation |
|---|---|
| Clinic open 9–17 | rangeMask(108, 204) |
| Split shift (morning + evening) | maskA | maskB |
| Doctor's own hours | doctorMask & clinicMask |
| Existing appointment | free &= ~busy & DAY_MASK |
| Vacation / clinic closure | unioned per doctor, then AND-NOT |
| Already in the past | free &= ~pastBitsMask(nowBit) |
| Doctor hit their daily cap | free = 0n |
Nine o'clock is minute 540, which is bit 108; five o'clock is bit 204. Opening hours stop being a pair of timestamps you compare against and become a run of ones shifted into position. A doctor who works mornings and evenings is two masks OR-ed together. A doctor's bookable time is their mask AND the clinic's. Cancelling an appointment is not a deletion and a rebuild — it is a bit going back to one.
One discipline makes the whole thing safe: every ~ and every << is followed by & DAY_MASK. BigInt is unbounded, so ~x has infinitely many leading ones and << happily walks bits past midnight. Without that mask you get phantom availability at 2am on a day that does not exist. It is the kind of invariant that has to be stated once, at the top of the file, and then never violated.
Finding runs without scanning
A free bit is not a bookable bit. A 60-minute appointment needs twelve consecutive free slots, and the naive check is to walk forward from every candidate start.
let result = freeBits;
for (let i = 1; i < durationBits; i++) {
result &= freeBits >> BigInt(i);
}This is the classic shift-and-AND run detector, and it replaces 288 × D comparisons with D − 1 operations. Each of those operations is word-parallel: a 288-bit value is about five machine words, so a 60-minute service is 11 ANDs — call it 55 word-operations to find every legal start time in an entire doctor-day. The array version does thousands.
Chained bookings, aligned by shifting
The part we are happiest with is multi-service bookings. If someone books three services back to back, service two must start at T plus service one's duration plus a buffer, and service three after that. The obvious implementation tests candidate start times and walks the chain forward from each one.
const aligned = startsThisService >> BigInt(cumulativeOffsetBits);
chainStarts &= aligned;
cumulativeOffsetBits += durationBitsList[i] + bufferBits;
if (chainStarts === 0n) break;Rather than testing times, it shifts each service's entire feasibility mask back by its cumulative offset, so every service in the chain is expressed relative to the same origin T. Then it just ANDs them. A three-service chain with buffers is three shifts and three ANDs, and the moment chainStarts hits zero the chain is provably impossible and the loop bails. What is left is snapped to the customer-facing 15- or 30-minute grid with applyGridMask, because patients should not be offered 09:35.
What it actually costs
Per day, the work is one mask build, one operation per existing appointment, the sum of (Dᵢ − 1) ANDs across services, and a fixed ~58-iteration pass for the grid. Thirty days across ten doctors is on the order of a few thousand five-word BigInt operations.
The whole month-long availability query is four SQL round-trips — clinic info, services with their qualified doctors, appointments, and per-service cap counts — all indexed and all loaded once, outside the day loop. The database is roughly 99% of the wall clock. The algorithm is noise, which is exactly where you want your scheduling logic to sit.
It is also stateless. Availability is computed per request and thrown away. There is no precomputed slot table, which means there is no cache to invalidate when somebody books — the single hardest bug class in booking systems simply does not exist here, because the thing that could go stale was never stored.
The backstop
Fast availability is a read-side optimisation, and read-side optimisations must never be the only thing standing between you and a double booking. The database enforces that independently, with an exclusion constraint on overlapping time ranges:
EXCLUDE USING gist (
doctor_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
)Two requests racing for the same slot cannot both win, regardless of what the availability engine believed a moment earlier. The bitmask makes the common path fast; Postgres makes the rare path correct.
What we took from it
- Choosing the right representation removes work rather than optimising it. None of these operations are clever in isolation — they are only available because the day stopped being a list.
- Word-parallelism is free performance most application code never claims. A 288-bit AND costs about five machine words regardless of how many slots it decides.
- Transform the problem to a common origin instead of searching. The shift-align trick turned a nested search over candidate times into a single intersection.
- State the invariant where it is enforced. Every ~ and << needs & DAY_MASK, and that belongs in a comment at the top of the file, not in the reviewer's memory.
- Never let a fast read path be the only guard on a write. The exclusion constraint is what makes the optimisation safe to trust.
- Not caching is a feature. Stateless computation that is fast enough has no invalidation bugs, because there is nothing to invalidate.
