Contact
All articles

How to prevent double booking in a booking system

How to prevent double booking in a booking system: let the database pick the winner, hold slots with an expiry, and make payments repeat-safe.

Topic
AI and software
Published
Reading time
6 minutes
Written by
unblokd

Double booking happens when two requests check availability at the same moment, both see the slot as free, and both write. To prevent it, let the database decide the winner with one conditional write or a constraint, not two separate steps in your code. Hold the slot temporarily, with an expiry time, while the customer pays. Make payment handling repeat-safe, so a retried charge or a duplicate notification cannot book or charge twice.

If you use an off-the-shelf booking tool

Double bookings in a ready-made tool usually come from the edges:

  • Two calendars that are not synced. Pick one as the source of truth and connect everything else to it.
  • Manual entries. A booking on paper is invisible to the system. Enter every booking in the same tool, right away.
  • Capacity set too high. If one stylist or one room serves one booking at a time, set capacity per slot to one.

Test it: open the booking page in two browsers, pick the same slot, and confirm together. If both succeed, ask the vendor how it decides a winner. The rest of this article is for custom systems.

The problem: check, then write

The obvious code asks the database "is C12 free?", and if yes, writes "C12 is booked by me." In the gap of a few milliseconds between the two steps, a second request asks the same question and also hears "yes". Both write. This is a race condition. Careful application code cannot fix it, because two servers, or one server handling two requests at once, will hit the gap. Every request must pass through one referee: the database.

Hold and sold are different states

A seat is free, held (with a person and an expiry time), or sold. A hold is a temporary reservation: the buyer has a few minutes to pay. If time runs out, the seat goes back to free.

Make the database the referee

One statement checks and writes at once, so there is no gap:

UPDATE seat
SET status = 'held',
    held_by = 'buyer-42',
    hold_expires_at = now() + interval '5 minutes'
WHERE event_id = 1
  AND seat_label = 'C12'
  AND (status = 'free'
       OR (status = 'held' AND hold_expires_at < now()))
RETURNING seat_label;

One row returned means you won. Zero rows means someone else got there first: show "sorry, that seat was just taken." This is safe at PostgreSQL's default isolation level: when two updates hit the same row, the second one waits for the first to commit or roll back, then re-checks its WHERE clause against the new version of the row. The loser's check fails, so it changes nothing.

Two other tools also work:

  • A unique constraint. With one row per sold ticket, a unique key on (event, seat) makes the database reject a second insert for the same seat.
  • A row lock. SELECT ... FOR UPDATE locks the rows it selects, so you can read, decide and write without anyone slipping in.

Appointments: ranges, not seats

A 10:00 to 11:00 booking clashes with a 10:30 to 11:30 one, though no two rows are equal. A PostgreSQL exclusion constraint rejects any row whose range overlaps another's. With btree_gist you can limit the rule to the same staff member:

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE booking (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  staff_id bigint NOT NULL,
  during   tstzrange NOT NULL,
  EXCLUDE USING gist (staff_id WITH =, during WITH &&)
);

Now double-booking a person is impossible, whatever the application does. Our concept project Tailor (online booking that holds a time while you pay) is not client work.

Where Redis fits

For short holds, SET hold:1:C12 buyer-42 NX EX 300 creates a key only if it does not exist, with a 300-second expiry (Redis SET documentation). But keys vanish when they expire, caches restart, and a paused worker can wake after its key expired and carry on as if it still held the seat. Redis can say "probably held, back off". A conditional write in the database confirms the sale.

Expiry: who lets go of a hold?

A buyer closes their browser. Nothing tells the server. Do not use a timer in one server's memory: if that server restarts, the seat stays held for ever. Store the expiry time next to the hold, as in the SQL above, and treat any hold past its time as free. Use the database's clock, so all servers agree on "now".

Payments: the hold must outlive the payment

The hold has to outlast the payment attempt, with margin. If you hold for five minutes and card verification takes six, the seat goes back on sale while the first buyer is still paying. Payment providers add three more problems:

  • Retries. If your connection drops after you create a charge, you may not know if it went through. Stripe accepts an idempotency key with a POST request. It returns the saved result of the first request for repeats, so a retry cannot charge twice.
  • Duplicates. Stripe's webhook documentation says the same event can arrive more than once, and recommends logging the event IDs you have processed.
  • Order. The same page says events may arrive out of order, and failed deliveries are retried for up to three days in live mode.

Razorpay's webhook best practices describe at-least-once delivery with a unique event id header to spot duplicates. See our Razorpay integration guide.

If payment succeeds after the hold expired, decide the rule before launch. A common one: if the seat is still free or still held by that buyer, confirm the sale. If someone else took it, refund automatically and say so. Never leave a paid customer with no seat and no message.

What to test

  • Many buyers at once. Fire hundreds of requests at one seat. Exactly one must win.
  • A killed worker. Stop a process mid-booking. No seat should stay half-held.
  • Duplicate webhooks. Send one payment event three times. The buyer gets one ticket.
  • Late payment. Pay after the hold expired and check the refund path.

Planning a booking system? Tell us about your project.

Frequently asked questions

How to prevent double booking?

Let the database decide the winner: one conditional write, a unique constraint, or an exclusion constraint for time ranges. Hold the slot with an expiry while the customer pays, and make payment handling repeat-safe. In a ready-made tool, keep one calendar as the source of truth.

How to avoid double booking system design?

Put the rule in the database, not in application code. Model three states: free, held and sold. Store the expiry time with the hold and treat expired holds as free. Keep the hold longer than the payment attempt, and deduplicate payment events by ID.

What is a seat hold or temporary lock?

A short reservation with an expiry time. A buyer who picks a seat or slot gets exclusive access for a few minutes, enough to pay. If time runs out, the seat becomes free again. It sits between free and sold.

Should I use Redis or the database to lock a slot?

The database, for the final decision. Redis is good for quick, short holds, but keys expire, caches restart, and a paused worker can wake up believing it still holds the slot. Confirm the booking with a conditional write in the database.

What happens if payment succeeds after the hold expires?

Decide before launch. A common rule: if the slot is still free or still held by that buyer, confirm the booking. If someone else took it, refund automatically and tell the buyer.

Next articleAI agent vs chatbot: the difference and which you need