The bug report always reads the same way: “we had twelve people turn up to a site that holds eight”, or its mirror, “the shift showed full but two spots were actually free”. Teams tend to treat this as a display problem and go looking for a stale cache or a slow refresh. It almost never is one. Double-booking is a state-model bug: the system has an incomplete idea of what a shift commitment is, and the gaps show up under load.
Here are the four places scheduling systems actually go wrong, from a platform that coordinates thousands of shifts across national campaigns.
1. The check-then-write race
The naive booking flow reads capacity, compares it to the current count, and inserts a booking if there is room. Read, decide, write. Under low traffic it works for years. Then a popular shift opens – the Saturday morning slot, the finish-line role – and two people load the page in the same second. Both reads see one spot left. Both checks pass. Both writes succeed. The shift is now oversold, and no line of code ever observed an invalid state.
Capacity checked at read time is a suggestion, not a constraint. The check and the write must be atomic: a conditional insert that re-counts inside the same transaction, a serialised update on the shift row, or a database constraint that makes the over-capacity write fail outright. Which mechanism you choose matters less than where the decision happens – it has to happen at write time, in the database, not at render time in the browser. Any capacity number a user is looking at is already stale; the write is the only moment the system truly knows.
2. A hold needs an explicit TTL
Sign-up is not instant. A volunteer picks a shift, then fills in their details, then confirms – and between the pick and the confirmation there is a gap measured in minutes. Decrement capacity at the pick, and every abandoned sign-up strands a spot; the shift reads full while seats sit empty behind half-finished forms. Decrement at confirmation, and two people can both be mid-form for the last spot, which is the check-then-write race again with a longer fuse.
The answer is a hold: a reservation that counts against capacity immediately but expires on its own. The expiry must be explicit – a timestamp on the hold itself, checked whenever capacity is computed – because a hold without an expiry is a slow leak of capacity. Every abandoned browser tab becomes a phantom volunteer. Give the hold a TTL of a few minutes, treat expired holds as free capacity, and both failure modes disappear: no stranded spots, no oversell through the form gap.
3. Held, confirmed and attended are different facts
A surprising number of systems model a shift commitment as one boolean: booked or not. In reality a commitment moves through distinct states, and each answers a different question.
- Held – a spot is reserved while sign-up completes. Counts against capacity, expires on a TTL, means nothing for reporting.
- Confirmed– the volunteer completed sign-up. This is who you expect on the day, who gets reminder emails, and what the coordinator's roster shows.
- Attended – the person actually showed up, recorded at check-in. This is what impact reports, thank-yous and anything billable should count.
Collapse these and the errors are predictable. Count confirmed as attended and your impact numbers are inflated by every no-show. Count attended as the roster and you cannot see a gap until it has already happened. Cancellation matters too: a cancelled commitment is not a deleted row but a state transition, because it frees capacity, should trigger waitlist promotion, and is history worth keeping – the volunteer who cancels three years running tells you something. Every transition gets its own timestamp, and the current state is whatever the transitions say it is.
4. Which day did that shift happen?
National campaigns report by day: how many shifts were confirmed today, how did day one compare to last year. The trap is bucketing those days in UTC. A shift starting at 00:15 on Saturday, New Zealand time, is still 12:15 Friday in UTC. Bucket by the UTC timestamp and that shift lands in Friday's numbers – the campaign's day-one total is wrong before anyone wakes up. New Zealand sits 12 to 13 hours ahead of UTC, so roughly half of every local day belongs to a different UTC day; this is not an edge case, it is half the data.
The rule: store instants in UTC, store the campaign's timezone alongside them, and derive the calendar day in the campaign's timezone at query time. A shift at 00:15 NZT belongs to the NZ day, full stop. The same discipline covers daylight saving – the day boundary moves with the zone rules, and because you derived the bucket instead of storing it, nothing needs migrating when the rules apply.
5. “Past” is a computed property, not a column
It is tempting to store a status like upcoming or past on the shift, usually so the UI can filter cheaply. But an is_pastflag is wrong the moment after it is written – it encodes the answer to “what time is it now?” in a place that cannot know. Keeping it right means a background job flipping flags on a timer, and every missed run or clock skew becomes a shift that is simultaneously finished and bookable.
Whether a shift is past is a comparison between its end time and the current moment, evaluated when someone asks – in the campaign's timezone, per the previous section. Store facts: start and end instants, state transitions with timestamps. Compute judgements: past, upcoming, in progress, full. Facts do not go stale; judgements recomputed from facts cannot drift. Most of the pathologies in this post reduce to that one distinction.
The one-paragraph version
Make the capacity decision atomic at write time. Give every hold an explicit TTL. Model held, confirmed and attended as separate states with timestamped transitions, and treat cancellation as a transition that frees capacity and promotes the waitlist. Bucket days in the campaign's timezone, not UTC. Store facts, compute judgements. None of this is exotic engineering – it is just modelling the domain as it actually behaves rather than as a form submission.
These are the rules PurposeTech is built on – the same commitment model has coordinated 28,000+ confirmed volunteer shifts across national appeals.