How to prevent double booking

How to prevent double booking holds the slot for five minutes, confirms it inside one D1 batch, and keeps a unique start so only one guest wins.

  • guide
  • vibe-coding
  • calendly

How to prevent double booking is the write that lets one guest keep 13:00 UTC. Calendly states the rule on the card. A final availability check runs inside the booking transaction.

You pay $12–$20 a person a month for Calendly. That is $144–$240 a year, and the price scales with the team. The verdict is “Very vibeable”. The first estimate is 6 hours for one meeting type, weekly hours, and bookings. The lock sits inside that row.

The Calendly teardown is why the slot list is already stale when two people click. The Calendly guide builds the page around that lock. The hosting guide puts the Worker and the D1 database slot on book.flaviocopes.com. This guide is the hold, the batch, and the unique start.

A vote on five times, with no hours to protect, is the Doodle guide. Doodle is $7–$9 a person a month, about 3 hours. Build that if the group is picking. Build the lock if you already published hours.

How to prevent double booking on one start

The meeting type is intro-30. Intro call, 30 minutes, Monday to Friday 09:00 to 17:00 in America/New_York, 15 minutes of buffer after, 4 hours of notice, a 14-day horizon. The start in this walk is 2026-10-27T13:00:00.000Z. That is 09:00 in New York and 14:00 in Rome.

Jordan Lee and Sam both have the page open. Both see 13:00. The list was true when it rendered. It is a copy.

Jordan clicks at 2026-10-26T18:02:00.000Z. The app writes a hold that expires at 2026-10-26T18:07:00.000Z. Jordan gets the form. Sam clicks at 18:04. The unexpired hold is already there, so Sam gets the slot-taken page and the next two starts that still fit, 14:00 UTC and 14:30 UTC. 13:30 UTC sits inside the 30-minute meeting plus the 15-minute buffer, so it is not an alternative.

If Jordan never submits, the next read after 18:07 treats the hold as gone. The slot is on the list again. There is no cron for that. The Calendly guide already releases holds on read.

If both guests somehow submit 13:00 together, one insert lands. The other hits the unique index. The card’s scenario is the same sentence: two guests, one confirmed booking, one slot-unavailable response.

Where the check and the insert split

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The card lists Astro, HTMX, and D1. This site is an Astro app on Cloudflare, so the check lives in the Worker and the rows live in D1. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. On that tab you use a real SQL transaction and the same unique index.

D1’s batch() sends a list of prepared statements. They run sequentially and non-concurrently. Cloudflare’s docs call that list a SQL transaction: if one statement fails, the batch rolls back. Two separate await calls are a different shape. Each one commits on its own. Jordan’s read can finish, Sam’s read can finish, and both inserts can still be attempted.

The confirm batch is three statements, sent together.

  1. Insert the confirmed booking for that meeting type and start, and only if the hours, the notice, the horizon, the buffer, and unexpired holds still allow it.
  2. Delete the hold that belongs to this guest.
  3. If the insert fails, the delete rolls back with it.

The unique index is what makes the second insert fail when two batches are queued. D1 runs queries one at a time on a database, which is in the limits. That queue is not the lock. Two requests can both observe an open slot before either insert runs.

TWO BROWSERS Jordan and Sam see 13:00 The list is already a copy ASTRO ON THE REQUEST Hold for five minutes Confirm is one batch() D1 One unexpired hold per start Unique confirmed start A failed insert rolls back STILL DOUBLE BOOKS A hold that lives in memory SELECT, then a later INSERT A grey button on the list Read replicas for this table A cron to delete holds
The list is a hint. The batch and the unique start are the lock.

On TanStack Start the screen is the same. The transaction is BEGIN on your SQL database, the insert, the hold delete, then COMMIT. A unique violation rolls that transaction back. You still show Sam the two alternatives.

What a hold and a booking store

A hold is the meeting type, the UTC start, an expiry, and a token the form sends back. Jordan’s hold is intro-30, 2026-10-27T13:00:00.000Z, expiry 2026-10-26T18:07:00.000Z. The holds table has one row per meeting type and start. A partial index cannot express “unexpired”, because now is not a fixed value SQLite will put in an index. The create-hold batch deletes that row only when expires_at is already past, then inserts. If Jordan’s row is still live, the delete removes nothing, the insert hits the unique pair, and the batch rolls back. Sam gets slot-taken.

A booking is the same pair plus the guest. Jordan Lee, [email protected], guest timezone Europe/Rome, status confirmed. Sam never gets a row.

The index covers confirmed rows only. A later cancel can keep the old row and drop it out of the index, which is how a start becomes free again. That cancel link is the 2-day row on the card, with reschedule. The index has to be partial from the start, or the cancel row blocks the hour forever.

CREATE UNIQUE INDEX one_confirmed_start
ON bookings (meeting_type_id, start_at)
WHERE status = 'confirmed'

SQLite allows that WHERE. D1 uses SQLite’s SQL. The insert that races Jordan uses the same start, fails this index, and the batch that contains it rolls back. Sam’s hold delete does not stick on that failure, so the form can be retried on a different start.

HOLD · JORDAN intro-30 · 13:00 UTC Expires 18:07 UTC BOOKING · CONFIRMED Jordan Lee · one row 2026-10-27 13:00 UTC SAM No booking row UNIQUE type + start ALTERNATIVES FOR SAM 14:00 UTC and 14:30 UTC
One confirmed row owns the start. Sam's page offers the next two starts outside the buffer.

Calendly’s scheduling API says the start must still be open when the invitee is created. A missing slot is a 404, and their docs say to query availability again and offer alternatives. The teardown walks that. You do not call their API. You copy the outcome: one winner, and a specific next step for the loser.

The screens a collision has to show

The public list can mark 13:00 as open for both guests. The three screens that matter are the held form, the slot-taken page, and the owner list with a single row.

Held form

Jordan’s form names the start in UTC and in both zones, and it names the hold expiry. Name, email, timezone. The submit posts the hold token. A refresh after 18:07 shows the form as expired and sends Jordan back to the list.

WIREFRAME · HELD FORM 13:00 UTC · held until 18:07 09:00 New York · 14:00 Rome Jordan Lee [email protected] Europe/Rome CONFIRM
The form is allowed because the hold row exists. The confirm still rechecks inside the batch.

Slot taken

Sam’s page says 13:00 just went. It lists 14:00 UTC and 14:30 UTC, with zone labels. It does not offer a disabled 13:00 button that still submits.

WIREFRAME · SLOT TAKEN 13:00 UTC just went 14:00 UTC · open 14:30 UTC · open 13:30 stays hidden, buffer Sam has no booking row
The loser gets two real starts. A grey 13:00 button is not one of them.

Owner list

/admin/bookings shows one row. Jordan, intro-30, 13:00 UTC, confirmed. A second line for Sam means the unique index is missing or the insert caught the error and wrote a row anyway.

WIREFRAME · OWNER LIST Jordan Lee · intro-30 13:00 UTC · confirmed One row for that start
Count the rows for 13:00. The page can look fine while a second insert sits in D1.

The path from two clicks to one row

Jordan picks 13:00. The hold insert is its own statement, with the same uniqueness rule for unexpired holds. Sam’s click finds that hold and stops. Jordan submits. The batch inserts the booking and deletes the hold. The owner list shows one row.

If the hold has expired by submit time, the batch refuses and the list shows 13:00 again. If a second batch inserts the same confirmed start, that statement fails and the whole batch rolls back.

JORDAN CLICKS 13:00 HOLD UNTIL 18:07 UTC SAM CLICKS THE SAME START? Hold still unexpired YES Slot taken 14:00 and 14:30 JORDAN SUBMITS One D1 batch Insert, drop hold UNIQUE START OK? Hold still inside the window YES One confirmed row NO Batch rolls back AFTER 18:07 WITH NO SUBMIT Next read shows 13:00 open No cleanup job
Sam loses at the hold. A second confirm loses at the unique index, and the batch undoes that attempt.

Prompt 1. A hold row in D1

The page and the hours already exist if you followed the Calendly guide. Paste this next.

Add holds for Slot. Node.js and Astro. Store them in D1.

When a guest picks a start, run one batch:
- delete the hold for that meeting type and start only if
  expires_at is already past
- insert meeting type, start_at in UTC, expires_at five
  minutes from now, and a token the form posts back

Unique (meeting_type_id, start_at) on holds.
If a live hold is still there, the delete removes nothing,
the insert fails, and the batch rolls back.
The second guest sees slot-taken, not a second form.
A partial index on expires_at > now is not valid here.

A hold in memory is invalid. Two Workers must see the same row.
Expired holds stay in the table. The slot list ignores them.
No cron.

Done when Jordan can hold 2026-10-27T13:00:00.000Z
and Sam, in another browser, cannot open a form for it
until that hold's expires_at has passed.

On the TanStack Start tab, the hold is a row in your SQL database, written from a server function. The five-minute rule stays.

What to check after the hold

Open /book/intro-30 as Jordan. Pick 13:00 UTC on 27 October 2026. The form shows the expiry. Open the same start as Sam before five minutes pass. Sam sees slot-taken. Wait out the hold without submitting. Reload the list. 13:00 is offered again.

Restart the dev server in the middle of the five minutes. Jordan’s form still submits, because the hold is in D1. If the restart cleared it, the hold was in memory.

Prompt 2. Confirm inside one batch

Confirm a booking in one D1 batch(). Do not await a SELECT
and then await an INSERT as two calls.

The batch:
- inserts one confirmed booking for that meeting type and
  UTC start, only when the weekly hours, the 15 minute
  buffer, the 4 hour notice, the 14 day horizon, confirmed
  bookings, and unexpired holds still allow it
- deletes this guest's hold
A failed statement rolls the whole batch back.

Add this index. Confirmed rows only:

CREATE UNIQUE INDEX one_confirmed_start
ON bookings (meeting_type_id, start_at)
WHERE status = 'confirmed'

A unique violation is the slot-taken response.
Do not catch it and insert a second row with a new id.

Done when two overlapping confirms for
2026-10-27T13:00:00.000Z leave one booking.
The loser gets 14:00 UTC and 14:30 UTC, not 13:30.

TanStack Start uses one SQL transaction for those two writes, with the same partial unique index. The overlap test is the same.

What to check after the batch

Submit Jordan’s form with [email protected] and Europe/Rome. The confirmation shows 13:00 UTC, 09:00 in New York, 14:00 in Rome. /admin/bookings has one row.

Run a second confirm for Sam at the same start, overlapping the first. Count rows. One. Sam’s response names 14:00 UTC and 14:30 UTC. 13:30 UTC is absent because of the buffer after a 13:00 meeting.

If you see two rows, the index is missing or the catch block inserted anyway. Read the migration. WHERE status = 'confirmed' has to be on the index. A unique index on every status will also block a later cancel from freeing the hour, and that cancel is the 2-day row.

Prompt 3. The loser page and the stale list

Build the slot-taken screen.

Copy: this start just went.
Then the nearest two starts that still pass hours, buffer,
notice, and horizon, each with a zone label.

The public list may still show 13:00 for a guest who loaded
it a minute ago. Submitting that stale button creates a hold
or confirms, and both paths use the rules above.
A disabled button is not the lock.

A missing email keeps Jordan's hold and redisplays the form.
Do not send email on this row.

What to check after the loser page

Load the list, then from the other browser book 13:00, then submit the stale 13:00 from the first browser. You land on slot-taken. The owner list still has one row. Clear the email field and submit. The hold is still there and the form still shows the name you typed.

Failures the agent ships anyway

Two round trips called a transaction

The code awaits “is it open?” and then awaits the insert. Both guests pass the read. D1 will run those queries one at a time and still accept both inserts if nothing unique rejects the second. Put the insert and the hold delete in one batch().

The unique index on all statuses

UNIQUE (meeting_type_id, start_at) with no WHERE treats a cancelled row as a taken hour. The 2-day row is supposed to free the slot on cancel. Limit the index to status = 'confirmed'.

A hold that dies on restart

The agent stores the five minutes in a Map on the Worker. The second instance, or a restart, never sees it. Sam gets a form. The hold table is the shared copy. The hosting guide is why that table is remote D1, not the file under .wrangler only.

The catch block writes a duplicate

The unique insert throws, the catch generates a new id, and Sam gets a booking. The catch returns slot-taken and the two alternatives. It does not retry with a different primary key.

Read replication on this database

A replica can still list 13:00 after Jordan’s insert has committed on the primary. Leave replication off for slot on this v1. The confirm reads and the insert go to the same D1 database, in the batch.

A cleanup cron for holds

Expired holds are invisible to the list and to the confirm. A cron that deletes them is a second moving part. The next read is enough. The card’s integrations are Google Calendar and email, both the 2-day row, not a sweeper.

Deploy the lock with the page

The host is the one from the hosting guide. One Worker, database slot, book.flaviocopes.com.

Book 13:00 from a phone as Jordan. From a laptop, within the five minutes, try Sam. Then look at /admin/bookings on the public host. One row. Export D1 before you cancel the $12 plan.

Google Calendar busy time is the 2-day row, 16 hours on the card. The guardrail you can ship today only knows about your own holds and bookings. The non-goal still stands: no teams, no round robin, no payments, no second calendar provider.

What to skip on purpose

Skip the cron, the replica, and the in-memory hold. Skip guest accounts. Jordan and Sam type a name and an email.

THIS LOCK Five-minute hold in D1 One batch() on confirm Unique confirmed start SKIP Hold stored in memory SELECT, then a later INSERT Cron and read replicas
Ship the hold, the batch, and the index. The sweeper and the replica wait.

Reschedule is cancel plus a new insert, in one transaction, with the same check. The teardown describes that pair. Add it when a guest asks to move. Payments and round robin stay on the month row, 160 hours, with every calendar provider.

Start now

Go to Build your own Calendly. Copy the Astro prompt, or TanStack Start if that is what you write.

The guardrail is the sentence to paste above it. Final check inside the booking transaction. Then the index on meeting type plus start, for confirmed rows only.

Hold 13:00 as Jordan. Submit as Sam before the hold expires. Open the owner list and count.

Related articles

Read all articles →