Two people open the calendar at the same moment. On both screens, 2 p.m. appears free. Both complete the booking and both receive a confirmation. Even though the calendar screen looked correct, the system may have allocated the same resource to two people.
This problem is not solved by refreshing the calendar more often. Seeing an opening and committing a booking are different operations. In the moment between them, another user, a call centre agent or an integration can take the same slot.
A reliable online booking system has to behave understandably while presenting availability, and preserve the business rule under concurrent requests while committing the record. The consultation-appointment example below is hypothetical and exists to explain the technical decisions.
Define the booking's resource and time range
First decide what is being reserved: a member of staff, a room, a vehicle, a seat, or a combination of these. “Tuesday at 2 p.m.” is not enough on its own. Start, end, resource and current status have to be considered together.
If a session runs 45 minutes and preparation plus clean-up takes 15, the interval during which the resource is blocked may be 60 minutes. Keeping the service duration the customer sees and the operational block duration in separate fields makes that distinction clear.
Use a shared rule at range boundaries
Bookings of 2–3 p.m. and 3–4 p.m. can sit next to each other if there is no additional buffer between them. A range approach that includes the start and excludes the end expresses this: [14:00, 15:00). PostgreSQL range types support that boundary behaviour. PostgreSQL: range types.
Store the time zone separately. Representing committed start and end instants in UTC can be useful; the local time the user saw and the business's time zone should be preserved too. PostgreSQL's timestamp-with-time-zone type does not store the original time zone that was entered. For recurring local appointments, decide separately how daylight saving changes will affect future occurrences. PostgreSQL: date and time types.
Why is “check, then insert” not enough on its own?
The first request asks the database “is this slot free?” and gets a yes. The second request can receive the same answer before the first record exists. Then both insert. That is a race condition.
A second check in application code can reduce the likelihood, but it offers no guarantee unless the operation is built to withstand concurrency. You need a mechanism that prevents another write from slipping in between the check and the insert.
Fixed slots and variable durations are different problems
If every resource has predefined, equal-length, single-occupancy slots, a uniqueness rule on resource and slot identifier can be used. For variable-duration bookings, however, blocking only identical start times is not enough. 2–3 p.m. and 2.30–3.30 p.m. start differently but overlap.
In PostgreSQL, an exclusion constraint that rejects overlapping time ranges for the same resource can be applied. The constraint has to be designed together with which record states consume capacity. On other databases, a resource row lock, or an appropriate isolation level with retries, may be required. PostgreSQL: constraints on non-overlapping ranges.
Test the method you choose on the database you actually use. Do not copy the single-occupancy rule directly to a class or event with capacity for several people; the atomic check of remaining capacity is a separate problem.
Separate the temporary hold from the committed booking
A user moving to the payment screen can be given a time-limited hold. That duration is the business's decision; a hold of a few minutes might be designed, for example. It must be clearly visible on screen and must be part of the capacity rule.
Think of the states as at least held, confirmed, cancelled and expired. Write down which event causes each transition. For example, the record moves from held to confirmed when payment is verified; the browser simply returning to a success page is not sufficient proof.
Allow for expiry clean-up being delayed. If the periodic job has stopped, an expired record must not lock capacity forever. Validity checking, and clean-up under appropriate locking, can be performed while accepting a new booking.
What happens if payment arrives late?
Payment confirmation can arrive after the hold expires. If the resource has been given to someone else in the meantime, automatically sending a firm confirmation to the first user recreates the conflict. The design needs a clear path for re-checking availability, offering an alternative or considering a refund.
Do not behave as though your payment provider and your own database share one transaction. Each system's outcome and its compensating step should be traceable. An operations colleague must be able to see which record is waiting on a manual decision.
Retried requests must not create a second appointment
A user whose connection drops may press the same button again. A unique idempotency key for the operation helps the server recognise the repeated request. That rule does not, however, stop two different customers taking the same slot; it does not replace capacity control.
A similar approach exists in payment APIs. Stripe documents safe retries using the same idempotency key. You still have to design the repeat handling and retention period for your own booking operation. Stripe: idempotent requests.
Payment notifications can also arrive more than once. Track processed events and the permitted state transitions. Stripe's documentation explains that events may be duplicated and that you should not depend on them arriving in a particular order. Stripe: handling webhooks.
Make the error state understandable for the user
If the slot has gone at the final moment, show an explanation such as “This time was just taken; you can choose one of the times below” rather than “server error”. Preserve the information they entered where you can, and offer nearby alternatives.
Generate the confirmation message only after the operation has committed. So that a delay in the notification service does not leave the booking itself ambiguous, make the booking reference and status visible in the account area. Track a failure to send email and a failure to create the appointment as separate error types.
Concurrency implementation checklist
- Run two parallel requests for the same resource and time.
- Try ranges with different start times that overlap.
- Test how a cancelled record affects capacity.
- Verify that all resources are reserved together in a multi-resource booking.
- Check that a retry with the same operation key creates no new record.
- Simulate a payment confirmation arriving after the hold expires.
- Test duplicated and out-of-order notifications.
- Trace booking, payment and notification history under a shared reference.
To review your booking software with WebWizz, you can share your service durations, your resources and an example conflict scenario through our contact page. A solid start means clarifying the capacity rules before drawing the calendar.
Frequently asked questions
Does refreshing the calendar every second prevent double bookings?
No. Concurrent requests can still occur between display and commit. Refreshing helps the user experience; the real protection has to be built into the design of the write operation and the database rule.
Is payment mandatory in every booking system?
No. Flows without payment may still need holds, confirmation, cancellation and no-show handling. If there is no payment, simplify the relevant states; keep the resource conflict check.
Should the call centre book through the same system?
Where possible, every channel should use the same capacity and record rules. An admin panel writing to the database by a different path can render the protection on the website ineffective.
Can the same slot reopen immediately after a cancellation?
It depends on the business rule. Preparation, staff scheduling or the refund process may require extra consideration. Define explicitly at what moment the cancelled state releases capacity.